@@ -5286,3 +5286,228 @@ async def handler(request: web.Request) -> web.Response:
52865286 assert (
52875287 len (resp ._raw_cookie_headers ) == 12
52885288 ), "All raw headers should be preserved"
5289+
5290+
5291+ @pytest .mark .parametrize ("status" , (307 , 308 ))
5292+ async def test_file_upload_307_308_redirect (
5293+ aiohttp_client : AiohttpClient , tmp_path : pathlib .Path , status : int
5294+ ) -> None :
5295+ """Test that file uploads work correctly with 307/308 redirects.
5296+
5297+ This demonstrates the bug where file payloads get incorrect Content-Length
5298+ on redirect because the file position isn't reset.
5299+ """
5300+ received_bodies : list [bytes ] = []
5301+
5302+ async def handler (request : web .Request ) -> web .Response :
5303+ # Store the body content
5304+ body = await request .read ()
5305+ received_bodies .append (body )
5306+
5307+ if str (request .url .path ).endswith ("/" ):
5308+ # Redirect URLs ending with / to remove the trailing slash
5309+ return web .Response (
5310+ status = status ,
5311+ headers = {
5312+ "Location" : str (request .url .with_path (request .url .path .rstrip ("/" )))
5313+ },
5314+ )
5315+
5316+ # Return success with the body size
5317+ return web .json_response (
5318+ {
5319+ "received_size" : len (body ),
5320+ "content_length" : request .headers .get ("Content-Length" ),
5321+ }
5322+ )
5323+
5324+ app = web .Application ()
5325+ app .router .add_post ("/upload/" , handler )
5326+ app .router .add_post ("/upload" , handler )
5327+
5328+ client = await aiohttp_client (app )
5329+
5330+ # Create a test file
5331+ test_file = tmp_path / f"test_upload_{ status } .txt"
5332+ content = b"This is test file content for upload."
5333+ await asyncio .to_thread (test_file .write_bytes , content )
5334+ expected_size = len (content )
5335+
5336+ # Upload file to URL with trailing slash (will trigger redirect)
5337+ f = await asyncio .to_thread (open , test_file , "rb" )
5338+ try :
5339+ async with client .post ("/upload/" , data = f ) as resp :
5340+ assert resp .status == 200
5341+ result = await resp .json ()
5342+
5343+ # The server should receive the full file content
5344+ assert result ["received_size" ] == expected_size
5345+ assert result ["content_length" ] == str (expected_size )
5346+
5347+ # Both requests should have received the same content
5348+ assert len (received_bodies ) == 2
5349+ assert received_bodies [0 ] == content # First request
5350+ assert received_bodies [1 ] == content # After redirect
5351+ finally :
5352+ await asyncio .to_thread (f .close )
5353+
5354+
5355+ @pytest .mark .parametrize ("status" , [301 , 302 ])
5356+ @pytest .mark .parametrize ("method" , ["PUT" , "PATCH" , "DELETE" ])
5357+ async def test_file_upload_301_302_redirect_non_post (
5358+ aiohttp_client : AiohttpClient , tmp_path : pathlib .Path , status : int , method : str
5359+ ) -> None :
5360+ """Test that file uploads work correctly with 301/302 redirects for non-POST methods.
5361+
5362+ Per RFC 9110, 301/302 redirects should preserve the method and body for non-POST requests.
5363+ """
5364+ received_bodies : list [bytes ] = []
5365+
5366+ async def handler (request : web .Request ) -> web .Response :
5367+ # Store the body content
5368+ body = await request .read ()
5369+ received_bodies .append (body )
5370+
5371+ if str (request .url .path ).endswith ("/" ):
5372+ # Redirect URLs ending with / to remove the trailing slash
5373+ return web .Response (
5374+ status = status ,
5375+ headers = {
5376+ "Location" : str (request .url .with_path (request .url .path .rstrip ("/" )))
5377+ },
5378+ )
5379+
5380+ # Return success with the body size
5381+ return web .json_response (
5382+ {
5383+ "method" : request .method ,
5384+ "received_size" : len (body ),
5385+ "content_length" : request .headers .get ("Content-Length" ),
5386+ }
5387+ )
5388+
5389+ app = web .Application ()
5390+ app .router .add_route (method , "/upload/" , handler )
5391+ app .router .add_route (method , "/upload" , handler )
5392+
5393+ client = await aiohttp_client (app )
5394+
5395+ # Create a test file
5396+ test_file = tmp_path / f"test_upload_{ status } _{ method .lower ()} .txt"
5397+ content = f"Test { method } file content for { status } redirect." .encode ()
5398+ await asyncio .to_thread (test_file .write_bytes , content )
5399+ expected_size = len (content )
5400+
5401+ # Upload file to URL with trailing slash (will trigger redirect)
5402+ f = await asyncio .to_thread (open , test_file , "rb" )
5403+ try :
5404+ async with client .request (method , "/upload/" , data = f ) as resp :
5405+ assert resp .status == 200
5406+ result = await resp .json ()
5407+
5408+ # The server should receive the full file content after redirect
5409+ assert result ["method" ] == method # Method should be preserved
5410+ assert result ["received_size" ] == expected_size
5411+ assert result ["content_length" ] == str (expected_size )
5412+
5413+ # Both requests should have received the same content
5414+ assert len (received_bodies ) == 2
5415+ assert received_bodies [0 ] == content # First request
5416+ assert received_bodies [1 ] == content # After redirect
5417+ finally :
5418+ await asyncio .to_thread (f .close )
5419+
5420+
5421+ async def test_file_upload_307_302_redirect_chain (
5422+ aiohttp_client : AiohttpClient , tmp_path : pathlib .Path
5423+ ) -> None :
5424+ """Test that file uploads work correctly with 307->302->200 redirect chain.
5425+
5426+ This verifies that:
5427+ 1. 307 preserves POST method and file body
5428+ 2. 302 changes POST to GET and drops the body
5429+ 3. No body leaks to the final GET request
5430+ """
5431+ received_requests : list [dict [str , Any ]] = []
5432+
5433+ async def handler (request : web .Request ) -> web .Response :
5434+ # Store request details
5435+ body = await request .read ()
5436+ received_requests .append (
5437+ {
5438+ "path" : str (request .url .path ),
5439+ "method" : request .method ,
5440+ "body_size" : len (body ),
5441+ "content_length" : request .headers .get ("Content-Length" ),
5442+ }
5443+ )
5444+
5445+ if request .url .path == "/upload307" :
5446+ # First redirect: 307 should preserve method and body
5447+ return web .Response (status = 307 , headers = {"Location" : "/upload302" })
5448+ elif request .url .path == "/upload302" :
5449+ # Second redirect: 302 should change POST to GET
5450+ return web .Response (status = 302 , headers = {"Location" : "/final" })
5451+ else :
5452+ # Final destination
5453+ return web .json_response (
5454+ {
5455+ "final_method" : request .method ,
5456+ "final_body_size" : len (body ),
5457+ "requests_received" : len (received_requests ),
5458+ }
5459+ )
5460+
5461+ app = web .Application ()
5462+ app .router .add_route ("*" , "/upload307" , handler )
5463+ app .router .add_route ("*" , "/upload302" , handler )
5464+ app .router .add_route ("*" , "/final" , handler )
5465+
5466+ client = await aiohttp_client (app )
5467+
5468+ # Create a test file
5469+ test_file = tmp_path / "test_redirect_chain.txt"
5470+ content = b"Test file content that should not leak to GET request"
5471+ await asyncio .to_thread (test_file .write_bytes , content )
5472+ expected_size = len (content )
5473+
5474+ # Upload file to URL that triggers 307->302->final redirect chain
5475+ f = await asyncio .to_thread (open , test_file , "rb" )
5476+ try :
5477+ async with client .post ("/upload307" , data = f ) as resp :
5478+ assert resp .status == 200
5479+ result = await resp .json ()
5480+
5481+ # Verify the redirect chain
5482+ assert len (resp .history ) == 2
5483+ assert resp .history [0 ].status == 307
5484+ assert resp .history [1 ].status == 302
5485+
5486+ # Verify final request is GET with no body
5487+ assert result ["final_method" ] == "GET"
5488+ assert result ["final_body_size" ] == 0
5489+ assert result ["requests_received" ] == 3
5490+
5491+ # Verify the request sequence
5492+ assert len (received_requests ) == 3
5493+
5494+ # First request (307): POST with full body
5495+ assert received_requests [0 ]["path" ] == "/upload307"
5496+ assert received_requests [0 ]["method" ] == "POST"
5497+ assert received_requests [0 ]["body_size" ] == expected_size
5498+ assert received_requests [0 ]["content_length" ] == str (expected_size )
5499+
5500+ # Second request (302): POST with preserved body from 307
5501+ assert received_requests [1 ]["path" ] == "/upload302"
5502+ assert received_requests [1 ]["method" ] == "POST"
5503+ assert received_requests [1 ]["body_size" ] == expected_size
5504+ assert received_requests [1 ]["content_length" ] == str (expected_size )
5505+
5506+ # Third request (final): GET with no body (302 changed method and dropped body)
5507+ assert received_requests [2 ]["path" ] == "/final"
5508+ assert received_requests [2 ]["method" ] == "GET"
5509+ assert received_requests [2 ]["body_size" ] == 0
5510+ assert received_requests [2 ]["content_length" ] is None
5511+
5512+ finally :
5513+ await asyncio .to_thread (f .close )
0 commit comments