codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=h1) Report
> Merging [#1750](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/113eea113844058816ed745d8dc9cb197e75cb95&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1750/graphs/tree.svg?width=650&height=150&src=pr&token=zOtkfNQ3h6)](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master     #1750   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         5037      5041    +4     
  Branches       823       824    +1     
=========================================
+ Hits          5037      5041    +4     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1750/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100.00% <ø> (ø)` | |
| [falcon/response\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1750/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlX2hlbHBlcnMucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=footer). Last update [113eea1...f7263b3](https://codecov.io/gh/falconry/falcon/pull/1750?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

diff --git a/docs/_newsfragments/1749.bugfix.rst b/docs/_newsfragments/1749.bugfix.rst
new file mode 100644
index 0000000..96777e5
--- /dev/null
+++ b/docs/_newsfragments/1749.bugfix.rst
@@ -0,0 +1,3 @@
+The :attr:`Response.downloadable_as <falcon.Response.downloadable_as>` property
+is now correctly encoding non-ASCII filenames as per
+`RFC 6266 <https://tools.ietf.org/html/rfc6266#appendix-D>`_ recommendations.
diff --git a/falcon/response.py b/falcon/response.py
index dc3850b..f3ec961 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -942,9 +942,14 @@ class Response:
         'Content-Disposition',
         """Set the Content-Disposition header using the given filename.
 
-        The value will be used for the *filename* directive. For example,
+        The value will be used for the ``filename`` directive. For example,
         given ``'report.pdf'``, the Content-Disposition header would be set
         to: ``'attachment; filename="report.pdf"'``.
+
+        As per `RFC 6266 <https://tools.ietf.org/html/rfc6266#appendix-D>`_
+        recommendations, non-ASCII filenames will be encoded using the
+        ``filename*`` directive, whereas ``filename`` will contain the US
+        ASCII fallback.
         """,
         format_content_disposition)
 
diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py
index 1d5fec0..81cecc9 100644
--- a/falcon/response_helpers.py
+++ b/falcon/response_helpers.py
@@ -14,6 +14,9 @@
 
 """Utilities for the Response class."""
 
+from falcon.util import uri
+from falcon.util.misc import isascii, secure_filename
+
 
 def header_property(name, doc, transform=None):
     """Create a header getter/setter.
@@ -81,7 +84,25 @@ def format_range(value):
 def format_content_disposition(value):
     """Formats a Content-Disposition header given a filename."""
 
-    return 'attachment; filename="' + value + '"'
+    # NOTE(vytas): RFC 6266, Appendix D.
+    #   Include a "filename" parameter when US-ASCII ([US-ASCII]) is
+    #   sufficiently expressive.
+    if isascii(value):
+        return 'attachment; filename="' + value + '"'
+
+    # NOTE(vytas): RFC 6266, Appendix D.
+    #   * Include a "filename*" parameter where the desired filename cannot be
+    #     expressed faithfully using the "filename" form.  Note that legacy
+    #     user agents will not process this, and will fall back to using the
+    #     "filename" parameter's content.
+    #   * When a "filename*" parameter is sent, to also generate a "filename"
+    #     parameter as a fallback for user agents that do not support the
+    #     "filename*" form, if possible.
+    #   * When a "filename" parameter is included as a fallback (as per above),
+    #     "filename" should occur first, due to parsing problems in some
+    #     existing implementations.
+    return "attachment; filename=%s; filename*=UTF-8''%s" % (
+        secure_filename(value), uri.encode(value))
 
 
 def format_etag_header(value):

Response.downloadable_as does not follow RFC 6266 recommendations for UTF-8
At the time of writing, [Response.downloadable_as](https://falcon.readthedocs.io/en/stable/api/request_and_response.html#falcon.Response.downloadable_as) always uses the `attachment; filename="filename.ext"` form regardless of the provided filename. However, [RFC 6266](https://tools.ietf.org/html/rfc6266) specifies that a quoted `filename` shall be interpreted as ISO-8859-1 (although some agents are able to sniff UTF-8), and recommends using `filename*` whenever `filename` cannot be faithfully represented with US-ASCII.
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 41f23f8..ce72a22 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -207,6 +207,16 @@ class RemoveHeaderResource:
         resp.downloadable_as = None
 
 
+class DownloadableResource:
+    def __init__(self, filename):
+        self.filename = filename
+
+    def on_get(self, req, resp):
+        resp.body = 'Hello, World!\n'
+        resp.content_type = falcon.MEDIA_TEXT
+        resp.downloadable_as = self.filename
+
+
 class ContentLengthHeaderResource:
 
     def __init__(self, content_length, body=None, data=None):
@@ -477,6 +487,28 @@ class TestHeaders:
             hist[name] += 1
             assert 1 == hist[name]
 
+    @pytest.mark.parametrize('filename,expected', [
+        ('report.csv', 'attachment; filename="report.csv"'),
+        ('Hello World.txt', 'attachment; filename="Hello World.txt"'),
+        (
+            'Bold Digit 𝟏.txt',
+            'attachment; filename=Bold_Digit_1.txt; '
+            "filename*=UTF-8''Bold%20Digit%20%F0%9D%9F%8F.txt",
+        ),
+        (
+            'Ångström unit.txt',
+            'attachment; filename=A_ngstro_m_unit.txt; '
+            "filename*=UTF-8''%C3%85ngstr%C3%B6m%20unit.txt",
+        ),
+    ])
+    def test_content_disposition_header(self, client, filename, expected):
+        resource = DownloadableResource(filename)
+        client.app.add_route('/', resource)
+        resp = client.simulate_get()
+
+        assert resp.status_code == 200
+        assert resp.headers['Content-Disposition'] == expected
+
     def test_unicode_location_headers(self, client):
         client.app.add_route('/', LocationHeaderUnicodeResource())
 

pip install -e .
parse_log_pytest
pytest
pytest pytest-randomly jsonschema coverage
apt-get update apt-get install -y gcc
3.8
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==25.3.0
coverage==7.6.1
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
-e git+https://github.com/falconry/falcon.git@d77191f732ae7a31bc29dfddff1a5778147a5cb5#egg=falcon
importlib_metadata==8.5.0
importlib_resources==6.4.5
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
jsonschema==4.23.0
jsonschema-specifications==2023.12.1
packaging @ file:///croot/packaging_1720101850331/work
pkgutil_resolve_name==1.3.10
pluggy @ file:///tmp/build/80754af9/pluggy_1648042571233/work
pytest @ file:///croot/pytest_1717793244625/work
pytest-randomly==3.15.0
referencing==0.35.1
rpds-py==0.20.1
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
zipp==3.20.2

name: falcon
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py38h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.1=py38h06a4308_0
  - pip=24.2=py38h06a4308_0
  - pluggy=1.0.0=py38h06a4308_1
  - pytest=7.4.4=py38h06a4308_0
  - python=3.8.20=he870216_0
  - readline=8.2=h5eee18b_0
  - setuptools=75.1.0=py38h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py38h06a4308_0
  - wheel=0.44.0=py38h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==25.3.0
      - coverage==7.6.1
      - falcon==3.0.0a2
      - importlib-metadata==8.5.0
      - importlib-resources==6.4.5
      - jsonschema==4.23.0
      - jsonschema-specifications==2023.12.1
      - pkgutil-resolve-name==1.3.10
      - pytest-randomly==3.15.0
      - referencing==0.35.1
      - rpds-py==0.20.1
      - zipp==3.20.2
prefix: /opt/conda/envs/falcon

tests/test_headers.py::TestHeaders::test_content_disposition_header[wsgi-Bold tests/test_headers.py::TestHeaders::test_content_disposition_header[wsgi-\xc5ngstr\xf6m
tests/test_headers.py::TestHeaders::test_override_default_media_type[text/plain; tests/test_headers.py::TestHeaders::test_override_default_media_type[text/plain-Hello tests/test_headers.py::TestHeaders::test_declared_content_length_overridden_by_no_body[asgi] tests/test_headers.py::TestHeaders::test_response_header_helpers_on_get[asgi] tests/test_headers.py::TestHeaders::test_response_set_and_get_header[asgi] tests/test_headers.py::TestHeaders::test_add_link_single[asgi] tests/test_headers.py::TestHeaders::test_custom_content_type[asgi] tests/test_headers.py::TestHeaders::test_vary_star[asgi] tests/test_headers.py::TestHeaders::test_content_disposition_header[asgi-\xc5ngstr\xf6m tests/test_headers.py::TestHeaders::test_default_value[asgi] tests/test_headers.py::TestHeaders::test_no_content_length[asgi-304 tests/test_headers.py::TestHeaders::test_required_header[asgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[asgi-use-credentials-</related/thing>; tests/test_headers.py::TestHeaders::test_unset_header[asgi-False] tests/test_headers.py::TestHeaders::test_set_headers_with_custom_class[asgi] tests/test_headers.py::TestHeaders::test_add_link_with_hreflang[asgi] tests/test_headers.py::TestHeaders::test_passthrough_request_headers[asgi] tests/test_headers.py::TestHeaders::test_headers_as_list[asgi] tests/test_headers.py::TestHeaders::test_unicode_headers_convertable[asgi] tests/test_headers.py::TestHeaders::test_no_content_length[asgi-204 tests/test_headers.py::TestHeaders::test_content_type_no_body[asgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[asgi-Use-Credentials-</related/thing>; tests/test_headers.py::TestHeaders::test_add_link_with_hreflang_multi[asgi] tests/test_headers.py::TestHeaders::test_add_link_complex[asgi] tests/test_headers.py::TestHeaders::test_no_content_type[asgi-304 tests/test_headers.py::TestHeaders::test_no_content_type[asgi-204 tests/test_headers.py::TestHeaders::test_expires_header[asgi] tests/test_headers.py::TestHeaders::test_unset_header[asgi-True] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[asgi-None-</related/thing>; tests/test_headers.py::TestHeaders::test_add_link_with_title_star[asgi] tests/test_headers.py::TestHeaders::test_content_disposition_header[asgi-report.csv-attachment; tests/test_headers.py::TestHeaders::test_vary_header[asgi-vary0-accept-encoding] tests/test_headers.py::TestHeaders::test_vary_header[asgi-vary1-accept-encoding, tests/test_headers.py::TestHeaders::test_content_length[asgi] tests/test_headers.py::TestHeaders::test_content_disposition_header[asgi-Bold tests/test_headers.py::TestHeaders::test_add_link_crossorigin[asgi-AnOnYmOUs-</related/thing>; tests/test_headers.py::TestHeaders::test_response_append_header[asgi] tests/test_headers.py::TestHeaders::test_add_link_with_anchor[asgi] tests/test_headers.py::TestHeaders::test_add_link_with_title[asgi] tests/test_headers.py::TestHeaders::test_override_default_media_type_missing_encoding[True] tests/test_headers.py::TestHeaders::test_declared_content_length_overriden_by_body_length[asgi] tests/test_headers.py::TestHeaders::test_declared_content_length_overriden_by_data_length[asgi] tests/test_headers.py::TestHeaders::test_unicode_location_headers[asgi] tests/test_headers.py::TestHeaders::test_request_multiple_header[asgi] tests/test_headers.py::TestHeaders::test_declared_content_length_on_head[asgi] tests/test_headers.py::TestHeaders::test_headers_with_custom_class_not_callable[asgi] tests/test_headers.py::TestHeaders::test_add_link_multiple[asgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[asgi-Anonymous-</related/thing>; tests/test_headers.py::TestHeaders::test_add_link_with_type_hint[asgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[asgi-anonymous-</related/thing>; tests/test_headers.py::TestHeaders::test_default_media_type[asgi] tests/test_headers.py::TestHeaders::test_content_disposition_header[asgi-Hello
tests/test_headers.py::TestHeaders::test_add_link_crossorigin[wsgi-use-credentials-</related/thing>; tests/test_headers.py::TestHeaders::test_add_link_complex[wsgi] tests/test_headers.py::TestHeaders::test_unset_header[wsgi-False] tests/test_headers.py::TestHeaders::test_add_link_with_title[wsgi] tests/test_headers.py::TestHeaders::test_response_header_helpers_on_get[wsgi] tests/test_headers.py::TestHeaders::test_unset_header[wsgi-True] tests/test_headers.py::TestHeaders::test_declared_content_length_overriden_by_data_length[wsgi] tests/test_headers.py::TestHeaders::test_add_link_multiple[wsgi] tests/test_headers.py::TestHeaders::test_expires_header[wsgi] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[asgi-HeaderNotSupported-Set-Cookie] tests/test_headers.py::TestHeaders::test_add_link_with_type_hint[wsgi] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[asgi-ValueError-Set-Cookie] tests/test_headers.py::TestHeaders::test_add_link_invalid_crossorigin_value[Allow-all] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[asgi-ValueError-set-cookie] tests/test_headers.py::TestHeaders::test_no_content_length[wsgi-204 tests/test_headers.py::TestHeaders::test_custom_content_type[wsgi] tests/test_headers.py::TestHeaders::test_default_value[wsgi] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[wsgi-HeaderNotSupported-seT-cookie] tests/test_headers.py::TestHeaders::test_no_content_length[wsgi-304 tests/test_headers.py::TestHeaders::test_headers_as_list[wsgi] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[wsgi-ValueError-Set-Cookie] tests/test_headers.py::TestHeaders::test_response_append_header[wsgi] tests/test_headers.py::TestHeaders::test_request_multiple_header[wsgi] tests/test_headers.py::TestHeaders::test_content_header_missing[asgi] tests/test_headers.py::TestHeaders::test_response_set_and_get_header[wsgi] tests/test_headers.py::TestHeaders::test_no_content_type[wsgi-204 tests/test_headers.py::TestHeaders::test_add_link_crossorigin[wsgi-anonymous-</related/thing>; tests/test_headers.py::TestHeaders::test_add_link_invalid_crossorigin_value[Lax] tests/test_headers.py::TestHeaders::test_default_media_type[wsgi] tests/test_headers.py::TestHeaders::test_add_link_invalid_crossorigin_value[MUST-REVALIDATE] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[wsgi-ValueError-seT-cookie] tests/test_headers.py::TestHeaders::test_add_link_with_hreflang[wsgi] tests/test_headers.py::TestHeaders::test_content_disposition_header[wsgi-Hello tests/test_headers.py::TestHeaders::test_unicode_location_headers[wsgi] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[wsgi-HeaderNotSupported-Set-Cookie] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[wsgi-ValueError-set-cookie] tests/test_headers.py::TestHeaders::test_vary_header[wsgi-vary0-accept-encoding] tests/test_headers.py::TestHeaders::test_content_header_missing[wsgi] tests/test_headers.py::TestHeaders::test_add_link_with_hreflang_multi[wsgi] tests/test_headers.py::TestHeaders::test_add_link_invalid_crossorigin_value[deny] tests/test_headers.py::TestHeaders::test_add_link_invalid_crossorigin_value[*] tests/test_headers.py::TestHeaders::test_content_length[wsgi] tests/test_headers.py::TestHeaders::test_passthrough_request_headers[wsgi] tests/test_headers.py::TestHeaders::test_content_type_no_body[wsgi] tests/test_headers.py::TestHeaders::test_required_header[wsgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[wsgi-Anonymous-</related/thing>; tests/test_headers.py::TestHeaders::test_no_content_type[wsgi-304 tests/test_headers.py::TestHeaders::test_content_disposition_header[wsgi-report.csv-attachment; tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[asgi-HeaderNotSupported-set-cookie] tests/test_headers.py::TestHeaders::test_add_link_invalid_crossorigin_value[Strict] tests/test_headers.py::TestHeaders::test_unicode_headers_convertable[wsgi] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[asgi-HeaderNotSupported-seT-cookie] tests/test_headers.py::TestHeaders::test_declared_content_length_overridden_by_no_body[wsgi] tests/test_headers.py::TestHeaders::test_override_default_media_type_missing_encoding[False] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[wsgi-HeaderNotSupported-set-cookie] tests/test_headers.py::TestHeaders::test_set_cookie_disallowed[asgi-ValueError-seT-cookie] tests/test_headers.py::TestHeaders::test_add_link_with_anchor[wsgi] tests/test_headers.py::TestHeaders::test_content_length_options[asgi] tests/test_headers.py::TestHeaders::test_add_link_with_title_star[wsgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[wsgi-Use-Credentials-</related/thing>; tests/test_headers.py::TestHeaders::test_vary_header[wsgi-vary1-accept-encoding, tests/test_headers.py::TestHeaders::test_declared_content_length_overriden_by_body_length[wsgi] tests/test_headers.py::TestHeaders::test_set_headers_with_custom_class[wsgi] tests/test_headers.py::TestHeaders::test_add_link_single[wsgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[wsgi-None-</related/thing>; tests/test_headers.py::TestHeaders::test_headers_with_custom_class_not_callable[wsgi] tests/test_headers.py::TestHeaders::test_declared_content_length_on_head[wsgi] tests/test_headers.py::TestHeaders::test_vary_star[wsgi] tests/test_headers.py::TestHeaders::test_content_length_options[wsgi] tests/test_headers.py::TestHeaders::test_add_link_crossorigin[wsgi-AnOnYmOUs-</related/thing>;
falcon/response.py docs/_newsfragments/1749.bugfix.rst falcon/response_helpers.py
falcon/response.py docs/_newsfragments/1749.bugfix.rst falcon/response_helpers.py