@@ -324,7 +324,7 @@ class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler):
324324 pass
325325
326326 def setUp (self ):
327- BaseTestCase .setUp (self )
327+ super () .setUp ()
328328 self .cwd = os .getcwd ()
329329 basetempdir = tempfile .gettempdir ()
330330 os .chdir (basetempdir )
@@ -343,7 +343,7 @@ def tearDown(self):
343343 except :
344344 pass
345345 finally :
346- BaseTestCase .tearDown (self )
346+ super () .tearDown ()
347347
348348 def check_status_and_reason (self , response , status , data = None ):
349349 def close_conn ():
@@ -399,6 +399,55 @@ def test_undecodable_filename(self):
399399 self .check_status_and_reason (response , HTTPStatus .OK ,
400400 data = support .TESTFN_UNDECODABLE )
401401
402+ def test_get_dir_redirect_location_domain_injection_bug (self ):
403+ """Ensure //evil.co/..%2f../../X does not put //evil.co/ in Location.
404+
405+ //netloc/ in a Location header is a redirect to a new host.
406+ https://github.com/python/cpython/issues/87389
407+
408+ This checks that a path resolving to a directory on our server cannot
409+ resolve into a redirect to another server.
410+ """
411+ os .mkdir (os .path .join (self .tempdir , 'existing_directory' ))
412+ url = f'/python.org/..%2f..%2f..%2f..%2f..%2f../%0a%0d/../{ self .tempdir_name } /existing_directory'
413+ expected_location = f'{ url } /' # /python.org.../ single slash single prefix, trailing slash
414+ # Canonicalizes to /tmp/tempdir_name/existing_directory which does
415+ # exist and is a dir, triggering the 301 redirect logic.
416+ response = self .request (url )
417+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
418+ location = response .getheader ('Location' )
419+ self .assertEqual (location , expected_location , msg = 'non-attack failed!' )
420+
421+ # //python.org... multi-slash prefix, no trailing slash
422+ attack_url = f'/{ url } '
423+ response = self .request (attack_url )
424+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
425+ location = response .getheader ('Location' )
426+ self .assertFalse (location .startswith ('//' ), msg = location )
427+ self .assertEqual (location , expected_location ,
428+ msg = 'Expected Location header to start with a single / and '
429+ 'end with a / as this is a directory redirect.' )
430+
431+ # ///python.org... triple-slash prefix, no trailing slash
432+ attack3_url = f'//{ url } '
433+ response = self .request (attack3_url )
434+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
435+ self .assertEqual (response .getheader ('Location' ), expected_location )
436+
437+ # If the second word in the http request (Request-URI for the http
438+ # method) is a full URI, we don't worry about it, as that'll be parsed
439+ # and reassembled as a full URI within BaseHTTPRequestHandler.send_head
440+ # so no errant scheme-less //netloc//evil.co/ domain mixup can happen.
441+ attack_scheme_netloc_2slash_url = f'https://pypi.org/{ url } '
442+ expected_scheme_netloc_location = f'{ attack_scheme_netloc_2slash_url } /'
443+ response = self .request (attack_scheme_netloc_2slash_url )
444+ self .check_status_and_reason (response , HTTPStatus .MOVED_PERMANENTLY )
445+ location = response .getheader ('Location' )
446+ # We're just ensuring that the scheme and domain make it through, if
447+ # there are or aren't multiple slashes at the start of the path that
448+ # follows that isn't important in this Location: header.
449+ self .assertTrue (location .startswith ('https://pypi.org/' ), msg = location )
450+
402451 def test_get (self ):
403452 #constructs the path relative to the root directory of the HTTPServer
404453 response = self .request (self .base_url + '/test' )
0 commit comments