Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-42988: Improve pydoc web server security #24285

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 14 additions & 18 deletions Lib/pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class or function within a module or module in a package. If the
import pkgutil
import platform
import re
import secrets
import sys
import sysconfig
import time
Expand Down Expand Up @@ -2280,6 +2281,10 @@ def onerror(modname):

# --------------------------------------- enhanced Web browser interface

# As of 2021, a secret with this number of bytes is secure. Update in the future
# if necessary.
SECRET_URL_TOKEN = secrets.token_urlsafe(64)

def _start_server(urlhandler, hostname, port):
"""Start an HTTP server thread on a specific port.

Expand Down Expand Up @@ -2411,7 +2416,7 @@ def ready(self, server):
self.serving = True
self.host = server.host
self.port = server.server_port
self.url = 'http://%s:%d/' % (self.host, self.port)
self.url = 'http://%s:%d/%s/' % (self.host, self.port, SECRET_URL_TOKEN)

def stop(self):
"""Stop the server and this thread nicely"""
Expand Down Expand Up @@ -2452,12 +2457,11 @@ def page(self, title, contents):
return '''\
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html><head><title>Pydoc: %s</title>
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
%s</head><body bgcolor="#f0f0f8">%s<div style="clear:both;padding-top:.5em;">%s</div>
</body></html>''' % (title, css_link, html_navbar(), contents)

def filelink(self, url, path):
return '<a href="getfile?key=%s">%s</a>' % (url, path)


html = _HTMLDoc()
Expand Down Expand Up @@ -2544,19 +2548,6 @@ def bltinlink(name):
'key = %s' % key, '#ffffff', '#ee77aa', '<br>'.join(results))
return 'Search Results', contents

def html_getfile(path):
"""Get and display a source file listing safely."""
path = urllib.parse.unquote(path)
with tokenize.open(path) as fp:
lines = html.escape(fp.read())
body = '<pre>%s</pre>' % lines
heading = html.heading(
'<big><big><strong>File Listing</strong></big></big>',
'#ffffff', '#7799ee')
contents = heading + html.bigsection(
'File: %s' % path, '#ffffff', '#ee77aa', body)
return 'getfile %s' % path, contents

def html_topics():
"""Index of topic texts available."""

Expand Down Expand Up @@ -2648,8 +2639,6 @@ def get_html_page(url):
op, _, url = url.partition('=')
if op == "search?key":
title, content = html_search(url)
elif op == "getfile?key":
title, content = html_getfile(url)
elif op == "topic?key":
# try topics first, then objects.
try:
Expand All @@ -2674,6 +2663,12 @@ def get_html_page(url):
title, content = html_error(complete_url, exc)
return html.page(title, content)

if url.startswith('/'):
url = url[1:]
# Make sure the secret token is inside before doing any URL operations.
if not secrets.compare_digest(url[:len(SECRET_URL_TOKEN)], SECRET_URL_TOKEN):
raise ValueError(f'Invalid secret token for {url}')
url = url[len(SECRET_URL_TOKEN):]
if url.startswith('/'):
url = url[1:]
if content_type == 'text/css':
Expand Down Expand Up @@ -2704,6 +2699,7 @@ def browse(port=0, *, open_browser=True, hostname='localhost'):
webbrowser.open(serverthread.url)
try:
print('Server ready at', serverthread.url)
print("Note: This URL is unique to you. Please don't share it.")
print(server_help_msg)
while serverthread.serving:
cmd = input('server> ')
Expand Down
23 changes: 13 additions & 10 deletions Lib/test/test_pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,8 @@ def test_url_search_package_error(self):
with self.assertRaisesRegex(ValueError, "ouch"):
import test_error_package # Sanity check

text = self.call_url_handler("search?key=test_error_package",
text = self.call_url_handler(f"{pydoc.SECRET_URL_TOKEN}/search"
f"?key=test_error_package",
"Pydoc: Search Results")
found = ('<a href="test_error_package.html">'
'test_error_package</a>')
Expand Down Expand Up @@ -1355,8 +1356,8 @@ class PydocUrlHandlerTest(PydocBaseTest):

def test_content_type_err(self):
f = pydoc._url_handler
self.assertRaises(TypeError, f, 'A', '')
self.assertRaises(TypeError, f, 'B', 'foobar')
self.assertRaises(TypeError, f, f'{pydoc.SECRET_URL_TOKEN}/A', '')
self.assertRaises(TypeError, f, f'{pydoc.SECRET_URL_TOKEN}/B', 'foobar')

def test_url_requests(self):
# Test for the correct title in the html pages returned.
Expand All @@ -1374,17 +1375,19 @@ def test_url_requests(self):
("topic?key=def", "Pydoc: KEYWORD def"),
("topic?key=STRINGS", "Pydoc: TOPIC STRINGS"),
("foobar", "Pydoc: Error - foobar"),
("getfile?key=foobar", "Pydoc: Error - getfile?key=foobar"),
]

with self.restrict_walk_packages():
for url, title in requests:
self.call_url_handler(url, title)

path = string.__file__
title = "Pydoc: getfile " + path
url = "getfile?key=" + path
self.call_url_handler(url, title)
with self.subTest(url):
self.call_url_handler(f"{pydoc.SECRET_URL_TOKEN}/{url}",
title)
# These should all fail without a token, see bpo-42988.
with self.restrict_walk_packages():
for url, title in requests:
with self.subTest(f"{url} should raise error without token"):
with self.assertRaises(ValueError):
self.call_url_handler(f"{url}", title)


class TestHelper(unittest.TestCase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Removed ``html_getfile`` function from :mod:`pydoc` since it allowed reading
an arbitrary file location. Also use a unique token only available to the
current user to prevent other users on the same computer from accessing the
:mod:`pydoc` Web server.