@@ -5286,3 +5286,228 @@ async def handler(request: web.Request) -> web.Response:
5286
5286
assert (
5287
5287
len (resp ._raw_cookie_headers ) == 12
5288
5288
), "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