Files
responder/tests/test_download_filename.py
kennethreitz ea5ba0d26b Add standalone Router composition; fix lifespan/dispatch/header correctness; 8.0 behavior changes
The first 8.0 sweep. Flagship: responder.Router records route declarations
without an API instance (Flask-blueprint style) and api.include_router()
replays them with prefix/tags/dependencies/auth composition, nesting, and
conflict guards.

Correctness: lifespan= now composes with on_event (background draining runs
again), view-style default routes dispatch instead of 500ing, CBV HEAD falls
back to on_get, tolerant cookie parsing, case-insensitive resp.headers,
async background tasks, async GraphQL resolvers, Jinja builtins survive
context assignment, identity-based dependency cycle detection, WSGI mounts
wrap once, WebSocket crashes send a 1011 close frame, and dependency
metadata matches actual imports.

Security: Content-Disposition filename escaping in resp.download(); bounded,
lock-guarded MemorySessionBackend with expiry-first eviction.

8.0 behavior changes: no implicit static/ mkdir, url_for raises
RouteNotFoundError and percent-encodes values, redirect() defaults to 307
(permanent=True for 308), charset= on encoded text responses (no more fake
'Encoding' header), and RFC 9512 application/yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:05:08 -04:00

70 lines
2.1 KiB
Python

"""resp.download() escapes user-derived filenames in Content-Disposition."""
import pytest
@pytest.fixture
def download_route(api, tmp_path):
f = tmp_path / "data.bin"
f.write_bytes(b"x")
def build(filename):
@api.route("/dl")
def dl(req, resp):
resp.download(f, filename=filename)
return api.requests.get("/dl")
return build
def test_quote_in_filename_is_escaped(download_route):
r = download_route('evil".txt')
disposition = r.headers["Content-Disposition"]
assert disposition == (
'attachment; filename="evil\\".txt"; filename*=UTF-8\'\'evil%22.txt'
)
def test_backslash_in_filename_is_escaped(download_route):
r = download_route("back\\slash.txt")
disposition = r.headers["Content-Disposition"]
assert disposition == (
'attachment; filename="back\\\\slash.txt"; '
"filename*=UTF-8''back%5Cslash.txt"
)
def test_crlf_in_filename_cannot_inject_headers(download_route):
r = download_route("a\r\nSet-Cookie: pwned=1\r\n.txt")
disposition = r.headers["Content-Disposition"]
assert "\r" not in disposition
assert "\n" not in disposition
assert "set-cookie" not in r.headers
assert "pwned" in disposition # sanitized into the value, not a new header
def test_nul_is_stripped(download_route):
r = download_route("nul\x00led.txt")
assert "\x00" not in r.headers["Content-Disposition"]
def test_token_filename_stays_plain_quoted(download_route):
r = download_route("report.csv")
assert r.headers["Content-Disposition"] == 'attachment; filename="report.csv"'
def test_filename_with_space_gets_rfc5987_form_too(download_route):
r = download_route("annual report.pdf")
assert r.headers["Content-Disposition"] == (
'attachment; filename="annual report.pdf"; '
"filename*=UTF-8''annual%20report.pdf"
)
def test_unicode_filename_uses_rfc5987_only(download_route):
r = download_route("résumé.pdf")
assert r.headers["Content-Disposition"] == (
"attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"
)