diff --git a/README.rst b/README.rst index f7ffa6a..38181a1 100644 --- a/README.rst +++ b/README.rst @@ -489,6 +489,8 @@ Changelog - #56, #70: Ignore non-packages when serving. - #58, #62: Log all http-requests. - #61: Possible to change welcome-msg. +- #77, #78: Avoid XSS by generating web-content with SimpleTemplate + instead of python's string-substs. 1.1.6 (2014-03-05) ------------------ diff --git a/pypiserver/__init__.py b/pypiserver/__init__.py index d0fc2ad..3a95c5b 100644 --- a/pypiserver/__init__.py +++ b/pypiserver/__init__.py @@ -1,5 +1,5 @@ -__version_info__ = (1, 1, 7, 'beta.0') -version = __version__ = "1.1.7-beta.0" +__version_info__ = (1, 1, 7, 'beta.1') +version = __version__ = "1.1.7-beta.1" def app(root=None, diff --git a/pypiserver/_app.py b/pypiserver/_app.py index f01a055..c42a446 100644 --- a/pypiserver/_app.py +++ b/pypiserver/_app.py @@ -12,12 +12,12 @@ try: except ImportError: from StringIO import StringIO as BytesIO -if sys.version_info >= (3, 0): +try: #PY3 from urllib.parse import urljoin -else: +except ImportError: #PY2 from urlparse import urljoin -from bottle import static_file, redirect, request, response, HTTPError, Bottle +from bottle import static_file, redirect, request, response, HTTPError, Bottle, template from pypiserver import __version__ from pypiserver.core import listdir, find_packages, store, get_prefixes, exists @@ -135,21 +135,21 @@ def configure(root=None, config.welcome_msg = dedent("""\ Welcome to pypiserver!

Welcome to pypiserver!

-

This is a PyPI compatible package index serving %(NUMPKGS)s packages.

+

This is a PyPI compatible package index serving {{NUMPKGS}} packages.

To use this server with pip, run the the following command:

-            pip install -i %(URL)ssimple/ PACKAGE [PACKAGE2...]
+            pip install -i {{URL}}simple/ PACKAGE [PACKAGE2...]
             

To use this server with easy_install, run the the following command:

-            easy_install -i %(URL)ssimple/ PACKAGE
+            easy_install -i {{URL}}simple/ PACKAGE
             

-

The complete list of all packages can be found here or via the simple index.

+

The complete list of all packages can be found here or via the simple index.

-

This instance is running version %(VERSION)s of the pypiserver software.

+

This instance is running version {{VERSION}} of the pypiserver software.

\ """) @@ -194,7 +194,8 @@ def root(): except: numpkgs = 0 - return config.welcome_msg % dict( + msg = config.welcome_msg + '\n' ## Ensure template() does not consider `msg` as filename! + return template(msg, URL=request.url, VERSION=__version__, NUMPKGS=numpkgs, @@ -269,11 +270,20 @@ def simpleindex_redirect(): @app.route("/simple/") @auth("list") def simpleindex(): - res = ["Simple Index\n"] - for x in sorted(get_prefixes(packages())): - res.append('%s
\n' % (x, x)) - res.append("") - return "".join(res) + links = sorted(get_prefixes(packages())) + tmpl = """\ + + + Simple Index + + +

Simple Index

+ % for p in links: + {{p}}
+ + + """ + return template(tmpl, links=links) @app.route("/simple/:prefix") @@ -285,19 +295,25 @@ def simple(prefix=""): fp += "/" files = [x.relfn for x in sorted(find_packages(packages(), prefix=prefix), key=lambda x: x.parsed_version)] - if not files: if config.redirect_to_fallback: return redirect("%s/%s/" % (config.fallback_url.rstrip("/"), prefix)) return HTTPError(404) - res = ["Links for %s\n" % prefix, - "

Links for %s

\n" % prefix] - for x in files: - abspath = urljoin(fp, "../../packages/%s" % x.replace("\\", "/")) - - res.append('%s
\n' % (abspath, os.path.basename(x))) - res.append("\n") - return "".join(res) + + links = [(os.path.basename(f), urljoin(fp, "../../packages/%s" % f.replace("\\", "/"))) for f in files] + tmpl = """\ + + + Links for {{prefix}} + + +

Links for {{prefix}}

+ % for file, href in links: + {{file}}
+ + + """ + return template(tmpl, prefix=prefix, links=links) @app.route('/packages') @@ -310,13 +326,20 @@ def list_packages(): files = [x.relfn for x in sorted(find_packages(packages()), key=lambda x: (os.path.dirname(x.relfn), x.pkgname, x.parsed_version))] - - res = ["Index of packages\n"] - for x in files: - x = x.replace("\\", "/") - res.append('%s
\n' % (urljoin(fp, x), x)) - res.append("\n") - return "".join(res) + links = [(f.replace("\\", "/"), urljoin(fp, f)) for f in files] + tmpl = """\ + + + Index of packages + + +

Index of packages

+ % for file, href in links: + {{file}}
+ + + """ + return template(tmpl, links=links) @app.route('/packages/:filename#.*#') diff --git a/pypiserver/welcome.html b/pypiserver/welcome.html index ac3fc99..18e426f 100644 --- a/pypiserver/welcome.html +++ b/pypiserver/welcome.html @@ -1,18 +1,18 @@ Welcome to pypiserver!

Welcome to pypiserver!

-

This is a PyPI compatible package index serving %(NUMPKGS)s packages.

+

This is a PyPI compatible package index serving {{NUMPKGS}} packages.

To use this server with pip, run the the following command:

-pip install -i %(URL)ssimple/ PACKAGE [PACKAGE2...]
+pip install -i {{URL}}simple/ PACKAGE [PACKAGE2...]
 

To use this server with easy_install, run the the following command:

-easy_install -i %(URL)ssimple/ PACKAGE
+easy_install -i {{URL}}simple/ PACKAGE
 

-

The complete list of all packages can be found here or via the simple index.

+

The complete list of all packages can be found here or via the simple index.

-

This instance is running version %(VERSION)s of the pypiserver software.

+

This instance is running version {{VERSION}} of the pypiserver software.

diff --git a/tests/sample_msg.html b/tests/sample_msg.html index 6dbbef4..2c91f02 100644 --- a/tests/sample_msg.html +++ b/tests/sample_msg.html @@ -1,7 +1,7 @@ Hello pypiserver tester! -%(URL)s -%(VERSION)s -%(NUMPKGS)s -%(PACKAGES)s -%(SIMPLE)s +{{URL}} +{{VERSION}} +{{NUMPKGS}} +{{PACKAGES}} +{{SIMPLE}} diff --git a/tests/test_app.py b/tests/test_app.py old mode 100755 new mode 100644 index 69fe912..f08c2b7 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -41,6 +41,32 @@ def testpriv(priv): return webtest.TestApp(priv) +@pytest.fixture(params=[" ", ## Mustcontain test below fails when string is empty. + "Hey there!", + "Hey there!", + ]) +def welcome_file_no_vars(request, root): + wfile = root.join("testwelcome.html") + wfile.write(request.param) + + return wfile + + +@pytest.fixture() +def welcome_file_all_vars(request, root): + msg =""" + {{URL}} + {{VERSION}} + {{NUMPKGS}} + {{PACKAGES}} + {{SIMPLE}} + """ + wfile = root.join("testwelcome.html") + wfile.write(msg) + + return wfile + + def test_root_count(root, testapp): resp = testapp.get("/") resp.mustcontain("PyPI compatible package index serving 0 packages") @@ -55,16 +81,39 @@ def test_root_hostname(testapp): # go("http://systemexit.de/") -def test_root_welcome_msg(root): - wmsg = "Hey there!" - wfile = root.join("testwelcome.html") - wfile.write(wmsg) - +def test_root_welcome_msg_no_vars(root, welcome_file_no_vars): from pypiserver import app - app = app(root=root.strpath, welcome_file=wfile.strpath) + app = app(root=root.strpath, welcome_file=welcome_file_no_vars.strpath) testapp = webtest.TestApp(app) resp = testapp.get("/") - resp.mustcontain(wmsg) + from pypiserver import __version__ as pver + resp.mustcontain(welcome_file_no_vars.read(), no=pver) + + +def test_root_welcome_msg_all_vars(root, welcome_file_all_vars): + from pypiserver import app + app = app(root=root.strpath, welcome_file=welcome_file_all_vars.strpath) + testapp = webtest.TestApp(app) + resp = testapp.get("/") + + from pypiserver import __version__ as pver + resp.mustcontain(pver) + + +def test_root_welcome_msg_antiXSS(testapp): + """https://github.com/pypiserver/pypiserver/issues/77""" + resp = testapp.get("/?Red", headers={"Host": "somehost.org"}) + resp.mustcontain("alert", "somehost.org", no="") + + +def test_root_remove_not_found_msg_antiXSS(testapp): + """https://github.com/pypiserver/pypiserver/issues/77""" + resp = testapp.post("/", expect_errors=True, + headers={"Host": "somehost.org"}, + params={':action': 'remove_pkg', + 'name': 'Red', + 'version':'1.1.1'}) + resp.mustcontain("alert", "somehost.org", no="") def test_packages_empty(testapp): @@ -243,4 +292,4 @@ def test_cache_control_set(root): root.join("foo_bar-1.0.tar.gz").write("") resp = app_with_cache.get("/packages/foo_bar-1.0.tar.gz") assert "Cache-Control" in resp.headers - assert resp.headers["Cache-Control"] == 'public, max-age=%s' % AGE \ No newline at end of file + assert resp.headers["Cache-Control"] == 'public, max-age=%s' % AGE