@@ -5357,3 +5357,228 @@ async def handler(request: web.Request) -> web.Response:
53575357 assert (
53585358 len (resp ._raw_cookie_headers ) == 12
53595359 ), "All raw headers should be preserved"
5360+
5361+
5362+ @pytest .mark .parametrize ("status" , (307 , 308 ))
5363+ async def test_file_upload_307_308_redirect (
5364+ aiohttp_client : AiohttpClient , tmp_path : pathlib .Path , status : int
5365+ ) -> None :
5366+ """Test that file uploads work correctly with 307/308 redirects.
5367+
5368+ This demonstrates the bug where file payloads get incorrect Content-Length
5369+ on redirect because the file position isn't reset.
5370+ """
5371+ received_bodies : List [bytes ] = []
5372+
5373+ async def handler (request : web .Request ) -> web .Response :
5374+ # Store the body content
5375+ body = await request .read ()
5376+ received_bodies .append (body )
5377+
5378+ if str (request .url .path ).endswith ("/" ):
5379+ # Redirect URLs ending with / to remove the trailing slash
5380+ return web .Response (
5381+ status = status ,
5382+ headers = {
5383+ "Location" : str (request .url .with_path (request .url .path .rstrip ("/" )))
5384+ },
5385+ )
5386+
5387+ # Return success with the body size
5388+ return web .json_response (
5389+ {
5390+ "received_size" : len (body ),
5391+ "content_length" : request .headers .get ("Content-Length" ),
5392+ }
5393+ )
5394+
5395+ app = web .Application ()
5396+ app .router .add_post ("/upload/" , handler )
5397+ app .router .add_post ("/upload" , handler )
5398+
5399+ client = await aiohttp_client (app )
5400+
5401+ # Create a test file
5402+ test_file = tmp_path / f"test_upload_{ status } .txt"
5403+ content = b"This is test file content for upload."
5404+ await asyncio .to_thread (test_file .write_bytes , content )
5405+ expected_size = len (content )
5406+
5407+ # Upload file to URL with trailing slash (will trigger redirect)
5408+ f = await asyncio .to_thread (open , test_file , "rb" )
5409+ try :
5410+ async with client .post ("/upload/" , data = f ) as resp :
5411+ assert resp .status == 200
5412+ result = await resp .json ()
5413+
5414+ # The server should receive the full file content
5415+ assert result ["received_size" ] == expected_size
5416+ assert result ["content_length" ] == str (expected_size )
5417+
5418+ # Both requests should have received the same content
5419+ assert len (received_bodies ) == 2
5420+ assert received_bodies [0 ] == content # First request
5421+ assert received_bodies [1 ] == content # After redirect
5422+ finally :
5423+ await asyncio .to_thread (f .close )
5424+
5425+
5426+ @pytest .mark .parametrize ("status" , [301 , 302 ])
5427+ @pytest .mark .parametrize ("method" , ["PUT" , "PATCH" , "DELETE" ])
5428+ async def test_file_upload_301_302_redirect_non_post (
5429+ aiohttp_client : AiohttpClient , tmp_path : pathlib .Path , status : int , method : str
5430+ ) -> None :
5431+ """Test that file uploads work correctly with 301/302 redirects for non-POST methods.
5432+
5433+ Per RFC 9110, 301/302 redirects should preserve the method and body for non-POST requests.
5434+ """
5435+ received_bodies : List [bytes ] = []
5436+
5437+ async def handler (request : web .Request ) -> web .Response :
5438+ # Store the body content
5439+ body = await request .read ()
5440+ received_bodies .append (body )
5441+
5442+ if str (request .url .path ).endswith ("/" ):
5443+ # Redirect URLs ending with / to remove the trailing slash
5444+ return web .Response (
5445+ status = status ,
5446+ headers = {
5447+ "Location" : str (request .url .with_path (request .url .path .rstrip ("/" )))
5448+ },
5449+ )
5450+
5451+ # Return success with the body size
5452+ return web .json_response (
5453+ {
5454+ "method" : request .method ,
5455+ "received_size" : len (body ),
5456+ "content_length" : request .headers .get ("Content-Length" ),
5457+ }
5458+ )
5459+
5460+ app = web .Application ()
5461+ app .router .add_route (method , "/upload/" , handler )
5462+ app .router .add_route (method , "/upload" , handler )
5463+
5464+ client = await aiohttp_client (app )
5465+
5466+ # Create a test file
5467+ test_file = tmp_path / f"test_upload_{ status } _{ method .lower ()} .txt"
5468+ content = f"Test { method } file content for { status } redirect." .encode ()
5469+ await asyncio .to_thread (test_file .write_bytes , content )
5470+ expected_size = len (content )
5471+
5472+ # Upload file to URL with trailing slash (will trigger redirect)
5473+ f = await asyncio .to_thread (open , test_file , "rb" )
5474+ try :
5475+ async with client .request (method , "/upload/" , data = f ) as resp :
5476+ assert resp .status == 200
5477+ result = await resp .json ()
5478+
5479+ # The server should receive the full file content after redirect
5480+ assert result ["method" ] == method # Method should be preserved
5481+ assert result ["received_size" ] == expected_size
5482+ assert result ["content_length" ] == str (expected_size )
5483+
5484+ # Both requests should have received the same content
5485+ assert len (received_bodies ) == 2
5486+ assert received_bodies [0 ] == content # First request
5487+ assert received_bodies [1 ] == content # After redirect
5488+ finally :
5489+ await asyncio .to_thread (f .close )
5490+
5491+
5492+ async def test_file_upload_307_302_redirect_chain (
5493+ aiohttp_client : AiohttpClient , tmp_path : pathlib .Path
5494+ ) -> None :
5495+ """Test that file uploads work correctly with 307->302->200 redirect chain.
5496+
5497+ This verifies that:
5498+ 1. 307 preserves POST method and file body
5499+ 2. 302 changes POST to GET and drops the body
5500+ 3. No body leaks to the final GET request
5501+ """
5502+ received_requests : List [Dict [str , Any ]] = []
5503+
5504+ async def handler (request : web .Request ) -> web .Response :
5505+ # Store request details
5506+ body = await request .read ()
5507+ received_requests .append (
5508+ {
5509+ "path" : str (request .url .path ),
5510+ "method" : request .method ,
5511+ "body_size" : len (body ),
5512+ "content_length" : request .headers .get ("Content-Length" ),
5513+ }
5514+ )
5515+
5516+ if request .url .path == "/upload307" :
5517+ # First redirect: 307 should preserve method and body
5518+ return web .Response (status = 307 , headers = {"Location" : "/upload302" })
5519+ elif request .url .path == "/upload302" :
5520+ # Second redirect: 302 should change POST to GET
5521+ return web .Response (status = 302 , headers = {"Location" : "/final" })
5522+ else :
5523+ # Final destination
5524+ return web .json_response (
5525+ {
5526+ "final_method" : request .method ,
5527+ "final_body_size" : len (body ),
5528+ "requests_received" : len (received_requests ),
5529+ }
5530+ )
5531+
5532+ app = web .Application ()
5533+ app .router .add_route ("*" , "/upload307" , handler )
5534+ app .router .add_route ("*" , "/upload302" , handler )
5535+ app .router .add_route ("*" , "/final" , handler )
5536+
5537+ client = await aiohttp_client (app )
5538+
5539+ # Create a test file
5540+ test_file = tmp_path / "test_redirect_chain.txt"
5541+ content = b"Test file content that should not leak to GET request"
5542+ await asyncio .to_thread (test_file .write_bytes , content )
5543+ expected_size = len (content )
5544+
5545+ # Upload file to URL that triggers 307->302->final redirect chain
5546+ f = await asyncio .to_thread (open , test_file , "rb" )
5547+ try :
5548+ async with client .post ("/upload307" , data = f ) as resp :
5549+ assert resp .status == 200
5550+ result = await resp .json ()
5551+
5552+ # Verify the redirect chain
5553+ assert len (resp .history ) == 2
5554+ assert resp .history [0 ].status == 307
5555+ assert resp .history [1 ].status == 302
5556+
5557+ # Verify final request is GET with no body
5558+ assert result ["final_method" ] == "GET"
5559+ assert result ["final_body_size" ] == 0
5560+ assert result ["requests_received" ] == 3
5561+
5562+ # Verify the request sequence
5563+ assert len (received_requests ) == 3
5564+
5565+ # First request (307): POST with full body
5566+ assert received_requests [0 ]["path" ] == "/upload307"
5567+ assert received_requests [0 ]["method" ] == "POST"
5568+ assert received_requests [0 ]["body_size" ] == expected_size
5569+ assert received_requests [0 ]["content_length" ] == str (expected_size )
5570+
5571+ # Second request (302): POST with preserved body from 307
5572+ assert received_requests [1 ]["path" ] == "/upload302"
5573+ assert received_requests [1 ]["method" ] == "POST"
5574+ assert received_requests [1 ]["body_size" ] == expected_size
5575+ assert received_requests [1 ]["content_length" ] == str (expected_size )
5576+
5577+ # Third request (final): GET with no body (302 changed method and dropped body)
5578+ assert received_requests [2 ]["path" ] == "/final"
5579+ assert received_requests [2 ]["method" ] == "GET"
5580+ assert received_requests [2 ]["body_size" ] == 0
5581+ assert received_requests [2 ]["content_length" ] is None
5582+
5583+ finally :
5584+ await asyncio .to_thread (f .close )
0 commit comments