@@ -330,7 +330,7 @@ class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler):
330
330
pass
331
331
332
332
def setUp (self ):
333
- BaseTestCase .setUp (self )
333
+ super () .setUp ()
334
334
self .cwd = os .getcwd ()
335
335
basetempdir = tempfile .gettempdir ()
336
336
os .chdir (basetempdir )
@@ -358,7 +358,7 @@ def tearDown(self):
358
358
except :
359
359
pass
360
360
finally :
361
- BaseTestCase .tearDown (self )
361
+ super () .tearDown ()
362
362
363
363
def check_status_and_reason (self , response , status , data = None ):
364
364
def close_conn ():
@@ -414,6 +414,55 @@ def test_undecodable_filename(self):
414
414
self .check_status_and_reason (response , HTTPStatus .OK ,
415
415
data = support .TESTFN_UNDECODABLE )
416
416
417
+ def test_get_dir_redirect_location_domain_injection_bug (self ):
418
+ """Ensure //evil.co/..%2f../../X does not put //evil.co/ in Location.
419
+
420
+ //netloc/ in a Location header is a redirect to a new host.
421
+ https://github.com/python/cpython/issues/87389
422
+
423
+ This checks that a path resolving to a directory on our server cannot
424
+ resolve into a redirect to another server.
425
+ """
426
+ os .mkdir (os .path .join (self .tempdir , 'existing_directory' ))
427
+ url = f'/python.org/..%2f..%2f..%2f..%2f..%2f../%0a%0d/../{ self .tempdir_name } /existing_directory'
428
+ expected_location = f'{ url } /' # /python.org.../ single slash single prefix, trailing slash
429
+ # Canonicalizes to /tmp/tempdir_name/existing_directory which does
430
+ # exist and is a dir, triggering the 301 redirect logic.
431
+ response = self .request (url )
432
+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
433
+ location = response .getheader ('Location' )
434
+ self .assertEqual (location , expected_location , msg = 'non-attack failed!' )
435
+
436
+ # //python.org... multi-slash prefix, no trailing slash
437
+ attack_url = f'/{ url } '
438
+ response = self .request (attack_url )
439
+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
440
+ location = response .getheader ('Location' )
441
+ self .assertFalse (location .startswith ('//' ), msg = location )
442
+ self .assertEqual (location , expected_location ,
443
+ msg = 'Expected Location header to start with a single / and '
444
+ 'end with a / as this is a directory redirect.' )
445
+
446
+ # ///python.org... triple-slash prefix, no trailing slash
447
+ attack3_url = f'//{ url } '
448
+ response = self .request (attack3_url )
449
+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
450
+ self .assertEqual (response .getheader ('Location' ), expected_location )
451
+
452
+ # If the second word in the http request (Request-URI for the http
453
+ # method) is a full URI, we don't worry about it, as that'll be parsed
454
+ # and reassembled as a full URI within BaseHTTPRequestHandler.send_head
455
+ # so no errant scheme-less //netloc//evil.co/ domain mixup can happen.
456
+ attack_scheme_netloc_2slash_url = f'https://pypi.org/{ url } '
457
+ expected_scheme_netloc_location = f'{ attack_scheme_netloc_2slash_url } /'
458
+ response = self .request (attack_scheme_netloc_2slash_url )
459
+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
460
+ location = response .getheader ('Location' )
461
+ # We're just ensuring that the scheme and domain make it through, if
462
+ # there are or aren't multiple slashes at the start of the path that
463
+ # follows that isn't important in this Location: header.
464
+ self .assertTrue (location .startswith ('https://pypi.org/' ), msg = location )
465
+
417
466
def test_get (self ):
418
467
#constructs the path relative to the root directory of the HTTPServer
419
468
response = self .request (self .base_url + '/test' )
0 commit comments