Repository: matrix-org/synapse

Issue:
Media-links now force download instead of just showing content inline in browser
### Description

PR #15680 just broke my ability to quickly open and view attachements.

The PR makes the media api to send the header `content-disposition: attachment` when answering requests for attachements. This again forces the browser to try to download the content, instead of just viewing it directly.

I, and many others, use less fancy clients, like [weechat-matrix](https://github.com/poljar/weechat-matrix), and attachements are often just presented as a link to the content. I used to be able to just open the link and view the content directly in my browser. Now I have to download the content first, and then open it.

Anyone getting a link to a matrix-attachement outside of a fancy matrix-client will probably be affected by this. This includes:
- IRC-users on the other side of a bridge (who I hear already aren't too pleased with matrix these days)
- Anyone getting a link to content from a matrix-server in general. It's not unheard of to share links to media from a matrix-chat outside matrix.

A way to turn this off, or the mimetype whitelist the PR hints at, would be greatly appreciated.

### Steps to reproduce

- Try to view https://matrix.dnns.no/_matrix/media/r0/download/dnns.no/cgFcEHtGykrmYbRjbzoxOUtl
- It will present as a download, instead of just opening as a picture in your browser

### Homeserver

matrix.dnns.no

### Synapse Version

1.87.0

### Installation Method

Docker (matrixdotorg/synapse)

### Database

PostgreSQL

### Workers

Single process

### Platform

Podman container on a VM running Debian, on host running OpenBSD

### Configuration

_No response_

### Relevant log output

```shell
There are no relevant logs here
```


### Anything else that would be useful to know?

Possible **workaround** until this gets fixed: Strip the `Content-Disposition` header from the response in your reverse proxy. I.e. `proxy_hide_header Content-Disposition;` in nginx (this goes in the same place as `proxy_pass`).


Discussion:
I am using the Matrix/IRC appservice, and when I click media links on the IRC side, I too am forced to download instead of just being able to watch them in my browser.

This worked in 1.85.x
I’d suggest against blindly stripping Content-Disposition as the change was a security fix.
Here's a new workaround (for nginx) that takes content-type into consideration:

```
map $upstream_http_content_type $matrix_media {
    "text/plain"            "inline";
    "text/css"              "inline";
    
    "application/pdf"       "inline";
    
    "image/gif"             "inline";
    "image/jpeg"            "inline";
    "image/avif"            "inline";
    "image/png"             "inline";
    "image/tiff"            "inline";
    "image/vnd.wap.wbmp"    "inline";
    "image/webp"            "inline";
    "image/x-icon"          "inline";
    "image/x-jng"           "inline";
    "image/x-ms-bmp"        "inline";
    
    "audio/basic"           "inline";
    "audio/midi"            "inline";
    "audio/mpeg"            "inline";
    "audio/ogg"             "inline";
    "audio/x-m4a"           "inline";
    "audio/x-realaudio"     "inline";
    
    "video/3gpp"            "inline";
    "video/mp2t"            "inline";
    "video/mp4"             "inline";
    "video/mpeg"            "inline";
    "video/quicktime"       "inline";
    "video/webm"            "inline";
    "video/x-flv"           "inline";
    "video/x-m4v"           "inline";
    "video/x-matroska"      "inline";
    "video/x-mng"           "inline";
    "video/x-ms-asf"        "inline";
    "video/x-ms-wmv"        "inline";
    "video/x-msvideo"       "inline";
    
    default                 "attachement";
}
```
The above goes in the http-context in nginx (above/outside your server-block).

Then this goes in your location/server-context (probably right beneath `proxy_pass`):
```
proxy_hide_header Content-Disposition;
add_header Content-Disposition $matrix_media;
```

This will strip the original filename when dealing with other filetypes than those we mapped, but I think this is preferable to having to download images.

@joshqou: Do you have any more details on the security issue? I'm not clear on what "NVT#1548992" is. If you can at least provide a list of content-types that should *not* be inlined, I can ammend this workaround.
As I am using Apache as my reverse proxy, this little piece of configuration should be equivalent to the NGinx config above

```
  # Temporarily fix the attachment / inline content-disposition issue introduced in Synapse 1.87
  <Location /_matrix/media/v3/download/>
    Header edit Content-Disposition "\battachment\b" "inline" "expr=resp('Content-Type') in { \
      'image/gif','image/jpeg','image/avif','image/png', 'image/svg+xml','image/tiff', \
      'image/vnd.wap.wbmp','image/webp','image/x-icon','image/x-jng','image/x-ms-bmp', \
      'audio/basic','audio/midi','audio/mpeg','audio/ogg','audio/x-m4a','audio/x-realaudio','video/3gpp', \
      'video/mp2t','video/mp4','video/mpeg','video/quicktime','video/webm','video/x-flv','video/x-m4v', \
      'video/x-matroska','video/x-mng','video/x-ms-asf','video/x-ms-wmv','video/x-msvideo'}"
  </Location>
```
**Edit: Removed non-media types (HTML, CSS etc)**

If you do not care about specific mime-types (which I think was the behaviour prior), you can skip the last part, like this

```
  # Temporarily fix the attachment / inline content-disposition issue introduced in Synapse 1.87
  <Location /_matrix/media/v3/download/>
    Header edit Content-Disposition "\battachment\b" "inline"
  </Location>
```
@dennisse @FlyveHest I don't know about the actual vulnerability but I don't think it's safe to serve `text/html` (and maybe `image/svg+xml`) with the `inline` disposition.
> I don't know about the actual vulnerability but I don't think it's safe to serve `text/html` (and maybe `image/svg+xml`) with the `inline` disposition.

@CyberShadow I'm guessing this is what led to the change, if you send inline html, it might lead to XSS issues in a web based client.

I can see the original PR that implemented this, #15680, originally intended to only change this behaviour for non-media type data, but due to complications ended up implementing it for everything served, which in turn broke many "dumb" clients.

Personally I think this should be rolled back until it can be done properly, it severely impedes on one of the core tenets of Matrix, being able to use it across an abundance of clients.
Can't say that I agree on neither the occasional or minor tags here, but if this is not something that is going to be fixed I think it would be a good idea to add information about this in the documentation, so admins that are using bridges on their homeservers does not have to find information in an issue that will be buried soon.

Also, the workaround completely counters the change to begin with, and if this really is a security issue (which I can't really see it being), will leave homeservers wanting to use bridges for "dumber" clients just as vulnerable as before.

This affects all my users on the IRC bridge at all times, and i'm guessing that all other non-web based clients are affected as well.
> I’d suggest against blindly stripping Content-Disposition as the change was a security fix.

@joshqou Can you possibly link to the CVE that necessitated this change?
> This affects all my users on the IRC bridge at all times

Arguably that's a bug of the IRC bridge in abusing the media repo as its way of sharing media. The same would happen in the future when the media repo becomes authenticated. So the IRC bridge needs a way to expose media itself anyhow.
I'm not sure that I would call it a bug, how else would any bridge serve any media, unless it is expected that a bridge will run its own proxy for media purposes? (And in turn, completely defeating the idea of authenticating media to begin with)

It's not as black and white as that I think, but I can see that at least the issue of authenticating the media repos is an old discussion, https://github.com/matrix-org/matrix-spec/issues/870, which haven't found a solution yet.
I agree with @t3chguy here.

> it is expected that a bridge will run its own proxy for media purposes?

I think that makes sense.

In encrypted rooms, the media download URLs will serve encrypted content (see e.g. https://github.com/hifi/heisenbridge/issues/227), so it already does not work there. I think it does make sense that bridges run their own HTTP server or bridge encrypted content in their own way.

After all, the `/media/v3/download/` endpoint is meant for Matrix client-to-server communication; bridges telling other non-Matrix software (or users!) to just go ahead and use that URL directly isn't right.

> And in turn, completely defeating the idea of authenticating media to begin with

Not necessarily. For example, when bridging to a protocol that has E2E messages but not E2E attachments, the bridge could host a web page which downloads and decrypts the media using a key specified in the URL hash; that way, the Matrix sender, bridge, and recipient will see the attachment cleartext, but the Matrix homeserver and the bridged service will not.

Hi all.

This was a bit unfortunate that it landed without much warning and has caused issues for folks. Though the part of the reason we landed this change was due to security concerns, so I would caution against fully reverting the change without careful thought.

As people have pointed out, there are two things that changes the situation here a bit:
* this is already an issue for media in encrypted rooms; and
* we'll be adding authentication to the media download endpoints (Matrix was never meant to provide an open file host, which can cause a lot of issues for people hosting Matrix)

As such, we as the core team are not planning to spend time trying to mitigate these changes, given it'll soon become moot. However, if people want to send a PR over that changes the header back to `inline` for some known safe mime types (e.g. jpg, png, etc) then we'd be happy to accept it.
Hi @erikjohnston 
 
>Though the part of the reason we landed this change was due to security concerns, so I would caution against fully reverting the change without careful thought.

Is there a publicly available discussion on this subject?  I'm somewhat curious as to what the security implications of serving data inline would be.
 
> * we'll be adding authentication to the media download endpoints (Matrix was never meant to provide an open file host, which can cause a lot of issues for people hosting Matrix)

While it is definitely a valid reason, I think it would be nice to consider allowing homeservers to decide if this should be possible or not on their own.

I think it would be a waste of resources having a bridge running a media cache itself, and just relaying the information through the bridges credentials seems as more work than needed, of course depending on what the security issue that forced the change is.

> As such, we as the core team are not planning to spend time trying to mitigate these changes, given it'll soon become moot. However, if people want to send a PR over that changes the header back to `inline` for some known safe mime types (e.g. jpg, png, etc) then we'd be happy to accept it.

Seems fair, i've "solved" the problem on my end via my reverse proxy, so it will continue to work until the media store becomes protected.

And that will most likely need to, if not handled, then adressed in bridges that need to bridge media as well.
I use matrix as a gateway to my IRC network with https://github.com/matrix-org/matrix-appservice-irc. Essentially matrix is just a fancy mobile client for IRC. None of the rooms on my homeserver are end-to-end encrypted except for private messages. 

Please do not remove the ability for the IRC appservice bridge to share uploaded files. Removing the ability for users to share media via matrix will mean that we will need to come up with a different solution for our network and stop using matrix entirely.

In the mean time we will be updating our nginx config to strip the content-disposition headers.

Resolving patch:
diff --git a/synapse/media/_base.py b/synapse/media/_base.py
--- a/synapse/media/_base.py
+++ b/synapse/media/_base.py
@@ -50,6 +50,39 @@
     "text/xml",
 ]
 
+# A list of all content types that are "safe" to be rendered inline in a browser.
+INLINE_CONTENT_TYPES = [
+    "text/css",
+    "text/plain",
+    "text/csv",
+    "application/json",
+    "application/ld+json",
+    # We allow some media files deemed as safe, which comes from the matrix-react-sdk.
+    # https://github.com/matrix-org/matrix-react-sdk/blob/a70fcfd0bcf7f8c85986da18001ea11597989a7c/src/utils/blobs.ts#L51
+    # SVGs are *intentionally* omitted.
+    "image/jpeg",
+    "image/gif",
+    "image/png",
+    "image/apng",
+    "image/webp",
+    "image/avif",
+    "video/mp4",
+    "video/webm",
+    "video/ogg",
+    "video/quicktime",
+    "audio/mp4",
+    "audio/webm",
+    "audio/aac",
+    "audio/mpeg",
+    "audio/ogg",
+    "audio/wave",
+    "audio/wav",
+    "audio/x-wav",
+    "audio/x-pn-wav",
+    "audio/flac",
+    "audio/x-flac",
+]
+
 
 def parse_media_id(request: Request) -> Tuple[str, str, Optional[str]]:
     """Parses the server name, media ID and optional file name from the request URI
@@ -153,8 +186,13 @@ def _quote(x: str) -> str:
 
     request.setHeader(b"Content-Type", content_type.encode("UTF-8"))
 
-    # Use a Content-Disposition of attachment to force download of media.
-    disposition = "attachment"
+    # A strict subset of content types is allowed to be inlined  so that they may
+    # be viewed directly in a browser. Other file types are forced to be downloads.
+    if media_type.lower() in INLINE_CONTENT_TYPES:
+        disposition = "inline"
+    else:
+        disposition = "attachment"
+
     if upload_name:
         # RFC6266 section 4.1 [1] defines both `filename` and `filename*`.
         #


Test patch:
diff --git a/tests/media/test_base.py b/tests/media/test_base.py
--- a/tests/media/test_base.py
+++ b/tests/media/test_base.py
@@ -12,7 +12,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from synapse.media._base import get_filename_from_headers
+from unittest.mock import Mock
+
+from synapse.media._base import add_file_headers, get_filename_from_headers
 
 from tests import unittest
 
@@ -36,3 +38,28 @@ def tests(self) -> None:
                 expected,
                 f"expected output for {hdr!r} to be {expected} but was {res}",
             )
+
+
+class AddFileHeadersTests(unittest.TestCase):
+    TEST_CASES = {
+        "text/plain": b"inline; filename=file.name",
+        "text/csv": b"inline; filename=file.name",
+        "image/png": b"inline; filename=file.name",
+        "text/html": b"attachment; filename=file.name",
+        "any/thing": b"attachment; filename=file.name",
+    }
+
+    def test_content_disposition(self) -> None:
+        for media_type, expected in self.TEST_CASES.items():
+            request = Mock()
+            add_file_headers(request, media_type, 0, "file.name")
+            request.setHeader.assert_any_call(b"Content-Disposition", expected)
+
+    def test_no_filename(self) -> None:
+        request = Mock()
+        add_file_headers(request, "text/plain", 0, None)
+        request.setHeader.assert_any_call(b"Content-Disposition", b"inline")
+
+        request.reset_mock()
+        add_file_headers(request, "text/html", 0, None)
+        request.setHeader.assert_any_call(b"Content-Disposition", b"attachment")
diff --git a/tests/media/test_media_storage.py b/tests/media/test_media_storage.py
--- a/tests/media/test_media_storage.py
+++ b/tests/media/test_media_storage.py
@@ -129,6 +129,8 @@ class _TestImage:
             a 404/400 is expected.
         unable_to_thumbnail: True if we expect the thumbnailing to fail (400), or
             False if the thumbnailing should succeed or a normal 404 is expected.
+        is_inline: True if we expect the file to be served using an inline
+            Content-Disposition or False if we expect an attachment.
     """
 
     data: bytes
@@ -138,6 +140,7 @@ class _TestImage:
     expected_scaled: Optional[bytes] = None
     expected_found: bool = True
     unable_to_thumbnail: bool = False
+    is_inline: bool = True
 
 
 @parameterized_class(
@@ -198,6 +201,25 @@ class _TestImage:
                 unable_to_thumbnail=True,
             ),
         ),
+        # An SVG.
+        (
+            _TestImage(
+                b"""<?xml version="1.0"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+
+<svg xmlns="http://www.w3.org/2000/svg"
+     width="400" height="400">
+  <circle cx="100" cy="100" r="50" stroke="black"
+    stroke-width="5" fill="red" />
+</svg>""",
+                b"image/svg",
+                b".svg",
+                expected_found=False,
+                unable_to_thumbnail=True,
+                is_inline=False,
+            ),
+        ),
     ],
 )
 class MediaRepoTests(unittest.HomeserverTestCase):
@@ -339,7 +361,11 @@ def test_disposition_filename_ascii(self) -> None:
         )
         self.assertEqual(
             headers.getRawHeaders(b"Content-Disposition"),
-            [b"attachment; filename=out" + self.test_image.extension],
+            [
+                (b"inline" if self.test_image.is_inline else b"attachment")
+                + b"; filename=out"
+                + self.test_image.extension
+            ],
         )
 
     def test_disposition_filenamestar_utf8escaped(self) -> None:
@@ -359,7 +385,12 @@ def test_disposition_filenamestar_utf8escaped(self) -> None:
         )
         self.assertEqual(
             headers.getRawHeaders(b"Content-Disposition"),
-            [b"attachment; filename*=utf-8''" + filename + self.test_image.extension],
+            [
+                (b"inline" if self.test_image.is_inline else b"attachment")
+                + b"; filename*=utf-8''"
+                + filename
+                + self.test_image.extension
+            ],
         )
 
     def test_disposition_none(self) -> None:
@@ -373,7 +404,10 @@ def test_disposition_none(self) -> None:
         self.assertEqual(
             headers.getRawHeaders(b"Content-Type"), [self.test_image.content_type]
         )
-        self.assertEqual(headers.getRawHeaders(b"Content-Disposition"), [b"attachment"])
+        self.assertEqual(
+            headers.getRawHeaders(b"Content-Disposition"),
+            [b"inline" if self.test_image.is_inline else b"attachment"],
+        )
 
     def test_thumbnail_crop(self) -> None:
         """Test that a cropped remote thumbnail is available."""