Skip to content

Commit 456047b

Browse files
committed
Match async proxy policy and preserve multipart filenames
1 parent 34d15b5 commit 456047b

4 files changed

Lines changed: 97 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* Async cancellation drains active multipart reads and resumable workers, including repeated cancellation, before releasing caller-owned files. Isolated public, token, SSE and raw TUS transports preserve proxy/TLS configuration without borrowing session or netrc account credentials. High-level TUS helpers validate metadata and preserve Assembly creation errors.
1010
* Explicit bearer authentication wins over ambient netrc or session auth. Assembly capability requests isolate account credentials while preserving custom deployment worker hosts. Async multipart uploads retain text-mode file support, and polling continues through `ASSEMBLY_REPLAYING`.
1111
* Preserved legacy HTTP Assembly URLs by upgrading known API hosts to HTTPS, and aligned regional host checks with the pinned contract. Async transports retain explicit session proxy settings. Text uploads use chunked transfer when encoding/newline conversion changes their byte length; worker cleanup also survives global event-loop shutdown.
12+
* Async multipart requests preserve spaces and Unicode in filenames with safe header quoting. Isolated async calls use aiohttp's environment proxy policy, including proxy-only netrc authentication.
1213
* Hardened upload and response edge cases: invalid service URLs and empty template IDs now fail fast, external absolute API URLs are no longer signed, sync TUS uploads now handle nameless streams and submit rate limits before uploading, sync non-resumable retries rewind seekable files and reject non-seekable retry streams, sync/async polling rate-limit retries reset after successful polls and cap repeated rate limits, async form fields match sync boolean serialization, async TUS cancellation waits for worker cleanup, async rate-limit backoff honors server `retryIn`, Smart CDN signing rejects invalid workspace slugs/reserved query keys, and sync non-JSON responses fall back to response text.
1314
* Hardened sync and async request handling by preserving custom `auth` constraints, quoting path IDs, and keeping explicit/custom service URLs compatible with local, CI, and [Transloadit Gateway](https://github.com/transloadit/gateway) deployments.
1415
* Fixed sync and async template creation to send the current API `template` payload shape.

‎tests/test_contract_endpoints.py‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,68 @@ async def scenario():
355355
asyncio.run(scenario())
356356

357357

358+
def test_async_proxy_selection_matches_signed_calls_with_all_proxy(monkeypatch):
359+
for name in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "no_proxy"):
360+
monkeypatch.delenv(name, raising=False)
361+
monkeypatch.setenv("ALL_PROXY", "socks5://127.0.0.2:1")
362+
monkeypatch.setenv("NO_PROXY", "")
363+
async def scenario():
364+
async with wire_client("async") as (client, server):
365+
await client.list_templates()
366+
await client.get_assembly(assembly_id=ASSEMBLY_ID)
367+
client.bearer_token = "selected-token"
368+
await client.list_templates()
369+
assert len(server.requests) == 3
370+
asyncio.run(scenario())
371+
372+
373+
def test_async_environment_proxy_preserves_proxy_netrc_only(monkeypatch, tmp_path):
374+
netrc = tmp_path / "netrc"
375+
netrc.write_text("machine 127.0.0.1 login proxy-user password proxy-password\nmachine 127.0.0.2 login origin-user password must-not-leak\n")
376+
monkeypatch.setenv("NETRC", str(netrc))
377+
monkeypatch.delenv("http_proxy", raising=False)
378+
monkeypatch.delenv("no_proxy", raising=False)
379+
monkeypatch.setenv("NO_PROXY", "")
380+
async def scenario():
381+
async with wire_client("async") as (client, server):
382+
monkeypatch.setenv("HTTP_PROXY", server.url)
383+
client.service = "http://127.0.0.2:1"
384+
await client.get_assembly(assembly_id=ASSEMBLY_ID)
385+
client.bearer_token = "selected-token"
386+
await client.list_templates()
387+
for request in server.requests:
388+
assert request["headers"]["Proxy-Authorization"] == aiohttp.BasicAuth("proxy-user", "proxy-password").encode()
389+
assert "Authorization" not in server.requests[0]["headers"]
390+
assert server.requests[1]["headers"]["Authorization"] == "Bearer selected-token"
391+
asyncio.run(scenario())
392+
393+
394+
@pytest.mark.parametrize("mode", ["sync", "async"])
395+
@pytest.mark.parametrize("filename", ["my photo.jpg", "caf\u00e9.jpg", "quote\"name.txt", "newline\r\nname.txt"])
396+
def test_multipart_preserves_original_filename(mode, filename):
397+
async def scenario():
398+
async with wire_client(mode) as (client, server):
399+
upload = io.BytesIO(b"data")
400+
upload.name = filename
401+
await call(client, "create_assembly", files={"file": upload})
402+
# Quoting must prevent header injection while preserving spaces/UTF-8.
403+
expected = filename.replace("\r", "%0D").replace("\n", "%0A").replace('"', '%22')
404+
assert server.requests[-1]["filenames"]["file"] == expected
405+
assert not upload.closed
406+
asyncio.run(scenario())
407+
408+
409+
@pytest.mark.parametrize("mode", ["sync", "async"])
410+
def test_multipart_field_names_cannot_inject_headers(mode):
411+
async def scenario():
412+
async with wire_client(mode) as (client, server):
413+
await call(client, "create_assembly", files={"file\r\nname": io.BytesIO(b"data")},
414+
extra_data={"quoted\"field": "value"})
415+
assert server.requests[-1]["fields"]["file%0D%0Aname"] == b"data"
416+
assert server.requests[-1]["fields"]["quoted%22field"] == "value"
417+
asyncio.run(scenario())
418+
419+
358420
ASSEMBLY_ID = "a" * 32
359421

360422

@@ -369,14 +431,17 @@ def __init__(self):
369431
async def handle(self, request):
370432
data = b""
371433
fields = {}
434+
filenames = {}
372435
if request.content_type == "multipart/form-data":
373436
for key, value in (await request.post()).items():
374437
fields[key] = value.file.read() if isinstance(value, web.FileField) else value
438+
if isinstance(value, web.FileField):
439+
filenames[key] = value.filename
375440
else:
376441
data = await request.read()
377442
if request.content_type == "application/x-www-form-urlencoded":
378443
fields = {key: values[0] for key, values in parse_qs(data.decode()).items()}
379-
record = {"method": request.method, "path": request.path, "body": data,
444+
record = {"method": request.method, "path": request.path, "body": data, "filenames": filenames,
380445
"fields": fields, "query": dict(request.query), "headers": CaseInsensitiveDict(request.headers)}
381446
self.requests.append(record)
382447
if request.method == "GET" and request.path == "/assemblies/" + ASSEMBLY_ID:

‎transloadit/async_request.py‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
TIMEOUT = 60
2525

2626

27+
def _multipart_parameter(value):
28+
return str(value).replace("\r", "%0D").replace("\n", "%0A").replace('"', "%22")
29+
30+
2731
class _NonClosingUploadStream(io.IOBase):
2832
def __init__(self, file_stream):
2933
self._file_stream = file_stream
@@ -129,11 +133,11 @@ async def _api_session(self, url):
129133
else:
130134
yield await self._ensure_session()
131135

132-
def _request_options(self, url):
136+
async def _request_options(self, url):
133137
headers = self._headers(url)
134138
options = {"headers": headers, "timeout": self._timeout(), "allow_redirects": False}
135139
if "Authorization" in headers:
136-
options.update(async_network_options(url, self._session))
140+
options.update(await async_network_options(url, self._session))
137141
return options
138142

139143
def _get_session_lock(self):
@@ -199,7 +203,7 @@ async def get(self, path, params=None):
199203
async with self._api_session(url) as session, session.get(
200204
url,
201205
params=self._to_request_payload(url, params),
202-
**self._request_options(url),
206+
**(await self._request_options(url)),
203207
) as response:
204208
return Response(
205209
data=await self._read_response_data(response),
@@ -221,9 +225,11 @@ async def _form_request(self, method, path, data=None, extra_data=None, files=No
221225

222226
uploads = []
223227
if files:
224-
form = aiohttp.FormData()
228+
# API2 preserves percent escapes in filename parameters. Match
229+
# Requests' RFC 7578 quoting while retaining spaces and UTF-8.
230+
form = aiohttp.FormData(quote_fields=False)
225231
for key, value in self._normalize_payload(data):
226-
form.add_field(key, value)
232+
form.add_field(_multipart_parameter(key), value)
227233

228234
for key, file_stream in files.items():
229235
wrapper = _NonClosingTextUploadStream if isinstance(file_stream, io.TextIOBase) else _NonClosingUploadStream
@@ -232,9 +238,9 @@ async def _form_request(self, method, path, data=None, extra_data=None, files=No
232238
filename = get_upload_filename(file_stream, key)
233239
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
234240
form.add_field(
235-
key,
241+
_multipart_parameter(key),
236242
upload,
237-
filename=filename,
243+
filename=_multipart_parameter(filename),
238244
content_type=content_type,
239245
)
240246
payload = form
@@ -245,7 +251,7 @@ async def _form_request(self, method, path, data=None, extra_data=None, files=No
245251
async with self._api_session(url) as session, getattr(session, method)(
246252
url,
247253
data=payload,
248-
**self._request_options(url),
254+
**(await self._request_options(url)),
249255
) as response:
250256
return Response(
251257
data=await self._read_response_data(response),
@@ -279,7 +285,7 @@ async def delete(self, path, data=None):
279285
async with self._api_session(url) as session, session.delete(
280286
url,
281287
data=data,
282-
**self._request_options(url),
288+
**(await self._request_options(url)),
283289
) as response:
284290
return Response(
285291
data=await self._read_response_data(response),
@@ -336,7 +342,7 @@ async def public(self, method, path, params=None, data=None):
336342
async with self._isolated_session() as session:
337343
async with session.request(method, url, params=params, data=data,
338344
headers=self._headers(), timeout=self._timeout(), allow_redirects=False,
339-
**async_network_options(url, self._session)) as response:
345+
**(await async_network_options(url, self._session))) as response:
340346
return Response(data=await self._read_response_data(response), status_code=response.status,
341347
headers=CaseInsensitiveDict(response.headers))
342348

@@ -345,7 +351,7 @@ async def issue_bearer_token(self, data=None):
345351
headers.update(self._headers())
346352
async with self._isolated_session() as session:
347353
async with session.post(url, data=self._normalize_payload(data), headers=headers,
348-
timeout=self._timeout(), allow_redirects=False, **async_network_options(url, self._session)) as response:
354+
timeout=self._timeout(), allow_redirects=False, **(await async_network_options(url, self._session))) as response:
349355
return Response(data=await self._read_response_data(response), status_code=response.status,
350356
headers=CaseInsensitiveDict(response.headers))
351357

@@ -354,7 +360,7 @@ async def tus(self, operation_id, location, headers=None, body=None):
354360
headers.update(self._headers())
355361
async with self._isolated_session() as session:
356362
async with session.request(spec["method"], url, data=body, headers=headers,
357-
timeout=self._timeout(), allow_redirects=False, **async_network_options(url, self._session)) as raw:
363+
timeout=self._timeout(), allow_redirects=False, **(await async_network_options(url, self._session))) as raw:
358364
data = await raw.read() if spec["binary_response"] else await self._read_response_data(raw)
359365
response = Response(data=data, status_code=raw.status, headers=CaseInsensitiveDict(raw.headers))
360366
if spec["method"] == "POST":
@@ -368,7 +374,7 @@ async def stream_status(self, url, *, read_timeout=120, max_event_size=1048576):
368374
timeout = aiohttp.ClientTimeout(total=None, sock_connect=TIMEOUT, sock_read=read_timeout)
369375
async with self._isolated_session() as session:
370376
async with session.get(url, headers={**self._headers(), "Accept": "text/event-stream"},
371-
timeout=timeout, allow_redirects=False, **async_network_options(url, self._session)) as response:
377+
timeout=timeout, allow_redirects=False, **(await async_network_options(url, self._session))) as response:
372378
if response.status != 200:
373379
raise StreamError(response.status)
374380
if response.headers.get("Content-Type", "").split(";", 1)[0].lower() != "text/event-stream":

‎transloadit/endpoint_transport.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Shared wire preparation for OAuth, SSE and raw TUS endpoints."""
22
import base64
3+
import asyncio
34
import copy
45
import os
56
from ipaddress import ip_address
@@ -8,6 +9,8 @@
89

910
from requests.structures import CaseInsensitiveDict
1011
from requests.utils import get_environ_proxies
12+
from aiohttp.helpers import get_env_proxy_for_url
13+
from yarl import URL
1114

1215
from ._contract import CONTRACT
1316
from .response import Response
@@ -19,16 +22,21 @@ def sync_network_options(url):
1922
"verify": os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("CURL_CA_BUNDLE") or True}
2023

2124

22-
def async_network_options(url, session=None):
25+
async def async_network_options(url, session=None):
2326
# aiohttp exposes the connector publicly, but its session-level proxy defaults
2427
# only through these attributes. Keep explicit proxy/auth ahead of environment.
2528
proxy = getattr(session, "_default_proxy", None)
2629
if proxy is not None:
2730
return {"proxy": proxy, "proxy_auth": getattr(session, "_default_proxy_auth", None)}
2831
if session is not None and not getattr(session, "trust_env", True):
2932
return {"proxy": None}
30-
proxies = get_environ_proxies(url)
31-
return {"proxy": proxies.get(urlsplit(url).scheme) or proxies.get("all")}
33+
try:
34+
# Use aiohttp's exact policy (including proxy-only netrc credentials),
35+
# and keep netrc file reads off the event loop like ClientSession does.
36+
proxy, proxy_auth = await asyncio.to_thread(get_env_proxy_for_url, URL(url))
37+
except LookupError:
38+
return {"proxy": None}
39+
return {"proxy": proxy, "proxy_auth": proxy_auth}
3240

3341

3442
def token_request(client, data):

0 commit comments

Comments
 (0)