-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Expand file tree
/
Copy pathtest_content_disposition_unicode.py
More file actions
156 lines (121 loc) · 6.25 KB
/
Copy pathtest_content_disposition_unicode.py
File metadata and controls
156 lines (121 loc) · 6.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
"""#1262: a non-Latin voice-profile name 500'd every download endpoint.
500 Internal Server Error: 'latin-1' codec can't encode characters in
position 22-25: ordinal not in range(256)
``attachment; filename="`` is exactly 22 characters long, so positions 22-25
were the first four characters of the reporter's own profile name. HTTP header
values are latin-1 by definition; every download endpoint interpolated the
filename straight into the header.
The sanitisers in front of those f-strings looked like they covered it — but
they all filtered with ``str.isalnum()``, which is ``True`` for *every*
alphabetic script. They stripped punctuation and let through exactly the
characters that break the header.
This was never one endpoint: the same ``isalnum()`` idiom was copy-pasted
across persona export, marketplace, stories, the OpenAI-compatible speech
route, and eight dub-export routes. All of them now go through one RFC 6266
builder, and a guard below keeps the next one from being written by hand.
"""
from __future__ import annotations
import pathlib
import re
import pytest
from core.http_headers import ascii_filename, content_disposition
REPO = pathlib.Path(__file__).resolve().parents[1]
# ── the header is always encodable ───────────────────────────────────────
@pytest.mark.parametrize(
"name",
[
"我的声音.ovsvoice", # Chinese — the reported shape
"私の声.ovsvoice", # Japanese
"내 목소리.ovsvoice", # Korean
"Моя речь.ovsvoice", # Cyrillic
"φωνή.ovsvoice", # Greek
"קול.ovsvoice", # Hebrew
"🎙️ voice.ovsvoice", # emoji
"Sébastien’s voix.ovsvoice", # accented Latin + smart quote
],
)
def test_the_header_survives_any_script(name):
header = content_disposition(name)
# The actual failure: Starlette encodes header values as latin-1.
header.encode("latin-1")
def test_the_exact_reported_failure():
"""A four-character CJK name — the one that produced 'position 22-25'."""
header = content_disposition("我的声音.ovsvoice")
header.encode("latin-1")
assert 'filename="' in header
assert "filename*=UTF-8''" in header
def test_the_real_name_is_preserved_for_modern_clients():
header = content_disposition("我的声音.ovsvoice")
# RFC 5987 percent-encoded UTF-8 — browsers prefer this over `filename=`.
assert "%E6%88%91%E7%9A%84%E5%A3%B0%E9%9F%B3" in header
def test_accented_latin_is_folded_not_deleted():
assert ascii_filename("Sébastien.ovsvoice") == "Sebastien.ovsvoice"
def test_an_entirely_non_ascii_name_still_yields_a_usable_filename():
"""Stripping CJK leaves only ".ovsvoice", which is not a filename."""
safe = ascii_filename("我的声音.ovsvoice")
assert safe.endswith(".ovsvoice")
stem = safe[: -len(".ovsvoice")]
assert stem and stem.strip("_ "), f"no usable stem in {safe!r}"
def test_ascii_names_are_left_alone():
assert ascii_filename("dubbed_output_en.mp4") == "dubbed_output_en.mp4"
header = content_disposition("dubbed_output_en.mp4")
assert 'filename="dubbed_output_en.mp4"' in header
@pytest.mark.parametrize("hostile", ['a"b.mp4', "a\\b.mp4", "a\r\nX-Evil: 1.mp4", "a/b/c.mp4"])
def test_quoting_and_header_injection_are_neutralised(hostile):
"""A dub filename comes from a video title, i.e. from the internet. A bare
quote would end the quoted-string; a CRLF would split the header."""
header = content_disposition(hostile)
header.encode("latin-1")
assert "\r" not in header and "\n" not in header
# Exactly the two parameters we intend, no smuggled third.
assert header.count("filename=") == 1
assert header.count("filename*=") == 1
def test_inline_disposition_is_supported():
"""The OpenAI-compatible speech route streams inline, not as a download."""
assert content_disposition("speech.mp3", disposition="inline").startswith("inline;")
# ── and no endpoint builds the header by hand again ──────────────────────
def test_no_router_interpolates_a_filename_into_the_header():
"""The recurrence guard. This bug shipped in ten places because the header
was written by f-string ten times; the eleventh must not compile."""
offenders = []
pattern = re.compile(r'"Content-Disposition"\s*:\s*f[\'"]')
for path in (REPO / "backend").rglob("*.py"):
if "test" in path.parts:
continue
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if pattern.search(line):
offenders.append(f"{path.relative_to(REPO)}:{i}")
assert offenders == [], (
"build the value with core.http_headers.content_disposition() — an "
"f-string here 500s on any non-latin-1 filename (#1262)"
)
def test_every_download_endpoint_actually_uses_the_builder():
"""Complements the guard above: proves the call sites were converted, not
merely reworded into something the regex misses."""
routers = REPO / "backend" / "api" / "routers"
users = {
path.name
for path in routers.rglob("*.py")
if "content_disposition(" in path.read_text(encoding="utf-8")
}
for expected in (
"personas.py",
"marketplace.py",
"stories.py",
"dub_export.py",
"openai_compat.py",
):
assert expected in users, f"{expected} still builds the header itself"
def test_a_hostile_custom_fallback_cannot_reach_the_header():
"""Review finding (#1262): `fallback` landed in `filename=` verbatim
whenever the real name folded away entirely, so a non-ASCII or CRLF
fallback walked past every guard the real name goes through."""
header = content_disposition("我的声音.ovsvoice", fallback='ev"il\r\nX-Evil: 1')
header.encode("latin-1")
assert "\r" not in header and "\n" not in header
assert header.count("filename=") == 1
assert header.count("filename*=") == 1
# A fallback that is ENTIRELY non-ASCII must still leave a usable name.
header = content_disposition("我的声音.ovsvoice", fallback="声音")
header.encode("latin-1")
assert 'filename=""' not in header