mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
ea5ba0d26b
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>
70 lines
2.1 KiB
Python
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"
|
|
)
|