Skip to content

Commit 1375a67

Browse files
committed
CRLF Injection Mitigation
Resolves #237 Previously, we were not running any sort of URL escaping on values passed in from the client that were used for redirects. This allowed injection attacks via URL encoded newlines in the original request. This update ensures that all user-supplied paths that are used as components of redirects are passed through `urllib.parse.quote()` (or the python 2 equivalent) prior to being used in a redirect response. Also specified 127.0.0.1 rather than 0.0.0.0 (the default) in server tests to avoid triggering firewall dialogs when testing on MacOS
1 parent 4ab0c77 commit 1375a67

File tree

6 files changed

+65
-13
lines changed

6 files changed

+65
-13
lines changed

pypiserver/_app.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def simpleindex():
250250
@auth("list")
251251
def simple(prefix=""):
252252
# PEP 503: require normalized prefix
253-
normalized = core.normalize_pkgname(prefix)
253+
normalized = core.normalize_pkgname_for_url(prefix)
254254
if prefix != normalized:
255255
return redirect('/simple/{0}/'.format(normalized), 301)
256256

@@ -327,11 +327,5 @@ def server_static(filename):
327327
@app.route('/:prefix')
328328
@app.route('/:prefix/')
329329
def bad_url(prefix):
330-
p = request.fullpath
331-
if p.endswith("/"):
332-
p = p[:-1]
333-
p = p.rsplit('/', 1)[0]
334-
p += "/simple/%s/" % prefix
335-
336-
return redirect(p)
337-
330+
"""Redirect unknown root URLs to /simple/."""
331+
return redirect(core.get_bad_url_redirect_path(request, prefix))

pypiserver/core.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
import re
1212
import sys
1313

14+
try: # PY3
15+
from urllib.parse import quote
16+
except ImportError: # PY2
17+
from urllib import quote
18+
1419
import pkg_resources
1520

1621
from . import Configuration
@@ -183,6 +188,11 @@ def normalize_pkgname(name):
183188
return re.sub(r"[-_.]+", "-", name).lower()
184189

185190

191+
def normalize_pkgname_for_url(name):
192+
"""Perform PEP 503 normalization and ensure the value is safe for URLs."""
193+
return quote(re.sub(r"[-_.]+", "-", name).lower())
194+
195+
186196
def is_allowed_path(path_part):
187197
p = path_part.replace("\\", "/")
188198
return not (p.startswith(".") or "/." in p)
@@ -273,6 +283,17 @@ def store(root, filename, save_method):
273283
save_method(dest_fn, overwrite=True) # Overwite check earlier.
274284

275285

286+
def get_bad_url_redirect_path(request, prefix):
287+
"""Get the path for a bad root url."""
288+
p = request.fullpath
289+
if p.endswith("/"):
290+
p = p[:-1]
291+
p = p.rsplit('/', 1)[0]
292+
prefix = quote(prefix)
293+
p += "/simple/{}/".format(prefix)
294+
return p
295+
296+
276297
def _digest_file(fpath, hash_algo):
277298
"""
278299
Reads and digests a file according to specified hashing-algorith.

tests/doubles.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Test doubles."""
2+
3+
4+
class Namespace(object):
5+
"""Simple namespace."""
6+
7+
def __init__(self, **kwargs):
8+
"""Instantiate the namespace with the provided kwargs."""
9+
for k, v in kwargs.items():
10+
setattr(self, k, v)

tests/test_app.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
# Local Imports
2222
from pypiserver import __main__, bottle
23+
2324
import tests.test_core as test_core
2425

2526

@@ -430,11 +431,11 @@ def test_upload_badFilename(package, root, testapp):
430431
def test_remove_pkg_missingNaveVersion(name, version, root, testapp):
431432
msg = "Missing 'name'/'version' fields: name=%s, version=%s"
432433
params = {':action': 'remove_pkg', 'name': name, 'version': version}
433-
params = dict((k, v) for k,v in params.items() if v is not None)
434+
params = dict((k, v) for k, v in params.items() if v is not None)
434435
resp = testapp.post("/", expect_errors=1, params=params)
435436

436437
assert resp.status == '400 Bad Request'
437-
assert msg %(name, version) in hp.unescape(resp.text)
438+
assert msg % (name, version) in hp.unescape(resp.text)
438439

439440

440441
def test_remove_pkg_notFound(root, testapp):

tests/test_core.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
from pypiserver import __main__, core
10+
from tests.doubles import Namespace
1011

1112

1213
## Enable logging to detect any problems with it
@@ -90,3 +91,20 @@ def test_hashfile(tmpdir, algo, digest):
9091
f = tmpdir.join("empty")
9192
f.ensure()
9293
assert core.digest_file(f.strpath, algo) == digest
94+
95+
96+
def test_redirect_prefix_encodes_newlines():
97+
"""Ensure raw newlines are url encoded in the generated redirect."""
98+
request = Namespace(
99+
fullpath='/\nSet-Cookie:malicious=1;'
100+
)
101+
prefix = '\nSet-Cookie:malicious=1;'
102+
newpath = core.get_bad_url_redirect_path(request, prefix)
103+
assert '\n' not in newpath
104+
105+
106+
def test_normalize_pkgname_for_url_encodes_newlines():
107+
"""Ensure newlines are url encoded in package names for urls."""
108+
assert '\n' not in core.normalize_pkgname_for_url(
109+
'/\nSet-Cookie:malicious=1;'
110+
)

tests/test_server.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,16 @@ def _run_server(packdir, port, authed, other_cli=''):
6161
'partial': "-Ptests/htpasswd.a.a -a update",
6262
}
6363
pswd_opts = pswd_opt_choices[authed]
64-
cmd = "%s -m pypiserver.__main__ -vvv --overwrite -p %s %s %s %s" % (
65-
sys.executable, port, pswd_opts, other_cli, packdir)
64+
cmd = (
65+
"%s -m pypiserver.__main__ -vvv --overwrite -i 127.0.0.1 "
66+
"-p %s %s %s %s" % (
67+
sys.executable,
68+
port,
69+
pswd_opts,
70+
other_cli,
71+
packdir,
72+
)
73+
)
6674
proc = subprocess.Popen(cmd.split(), bufsize=_BUFF_SIZE)
6775
time.sleep(SLEEP_AFTER_SRV)
6876
assert proc.poll() is None

0 commit comments

Comments
 (0)