CVE-2019-19775: Close open redirect in thumbnail view.

This closes an open redirect vulnerability, one case of which was
found by Graham Bleaney and Ibrahim Mohamed using Pysa.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
This commit is contained in:
Anders Kaseorg
2019-12-11 16:28:29 -08:00
committed by Tim Abbott
parent 1bf0a92eb6
commit b7c87a4d82
3 changed files with 25 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ import markdown
import logging import logging
import traceback import traceback
import urllib import urllib
import urllib.parse
import re import re
import os import os
import html import html
@@ -553,7 +554,7 @@ class InlineHttpsProcessor(markdown.treeprocessors.Treeprocessor):
found_imgs = walk_tree(root, lambda e: e if e.tag == "img" else None) found_imgs = walk_tree(root, lambda e: e if e.tag == "img" else None)
for img in found_imgs: for img in found_imgs:
url = img.get("src") url = img.get("src")
if not url.startswith("http://"): if urllib.parse.urlsplit(url).scheme != "http":
# Don't rewrite images on our own site (e.g. emoji). # Don't rewrite images on our own site (e.g. emoji).
continue continue
img.set("src", get_camo_url(url)) img.set("src", get_camo_url(url))

View File

@@ -4,6 +4,7 @@ import base64
import os import os
import sys import sys
import urllib import urllib
from urllib.parse import urljoin, urlsplit, urlunsplit
from django.conf import settings from django.conf import settings
from libthumbor import CryptoURL from libthumbor import CryptoURL
@@ -19,7 +20,8 @@ def is_thumbor_enabled() -> bool:
return settings.THUMBOR_URL != '' return settings.THUMBOR_URL != ''
def user_uploads_or_external(url: str) -> bool: def user_uploads_or_external(url: str) -> bool:
return url.startswith('http') or url.lstrip('/').startswith('user_uploads/') u = urlsplit(url)
return u.scheme != "" or u.netloc != "" or u.path.startswith("/user_uploads/")
def get_source_type(url: str) -> str: def get_source_type(url: str) -> str:
if not url.startswith('/user_uploads/'): if not url.startswith('/user_uploads/'):
@@ -33,16 +35,16 @@ def get_source_type(url: str) -> str:
def generate_thumbnail_url(path: str, def generate_thumbnail_url(path: str,
size: str='0x0', size: str='0x0',
is_camo_url: bool=False) -> str: is_camo_url: bool=False) -> str:
if not (path.startswith('https://') or path.startswith('http://')): path = urljoin("/", path)
path = '/' + path u = urlsplit(path)
if not is_thumbor_enabled(): if not is_thumbor_enabled():
if path.startswith('http://'): if u.scheme == "" and u.netloc == "":
return get_camo_url(path) return urlunsplit(u)
return path return get_camo_url(path)
if not user_uploads_or_external(path): if u.scheme == "" and u.netloc == "" and not u.path.startswith("/user_uploads/"):
return path return urlunsplit(u)
source_type = get_source_type(path) source_type = get_source_type(path)
safe_url = base64.urlsafe_b64encode(path.encode()).decode('utf-8') safe_url = base64.urlsafe_b64encode(path.encode()).decode('utf-8')

View File

@@ -148,6 +148,9 @@ class ThumbnailTest(ZulipTestCase):
image_url = 'http://images.foobar.com/12345' image_url = 'http://images.foobar.com/12345'
run_test_with_image_url(image_url) run_test_with_image_url(image_url)
image_url = '//images.foobar.com/12345'
run_test_with_image_url(image_url)
def test_local_file_type(self) -> None: def test_local_file_type(self) -> None:
def get_file_path_urlpart(uri: str, size: str='') -> str: def get_file_path_urlpart(uri: str, size: str='') -> str:
url_in_result = 'smart/filters:no_upscale()%s/%s/source_type/local_file' url_in_result = 'smart/filters:no_upscale()%s/%s/source_type/local_file'
@@ -281,7 +284,8 @@ class ThumbnailTest(ZulipTestCase):
with self.settings(THUMBOR_URL=''): with self.settings(THUMBOR_URL=''):
result = self.client_get("/thumbnail?url=%s&size=full" % (quoted_uri)) result = self.client_get("/thumbnail?url=%s&size=full" % (quoted_uri))
self.assertEqual(result.status_code, 302, result) self.assertEqual(result.status_code, 302, result)
self.assertEqual(uri, result.url) base = 'https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67'
self.assertEqual(base, result.url)
uri = 'http://www.google.com/images/srpr/logo4w.png' uri = 'http://www.google.com/images/srpr/logo4w.png'
quoted_uri = urllib.parse.quote(uri, safe='') quoted_uri = urllib.parse.quote(uri, safe='')
@@ -291,6 +295,14 @@ class ThumbnailTest(ZulipTestCase):
base = 'https://external-content.zulipcdn.net/external_content/7b6552b60c635e41e8f6daeb36d88afc4eabde79/687474703a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67' base = 'https://external-content.zulipcdn.net/external_content/7b6552b60c635e41e8f6daeb36d88afc4eabde79/687474703a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67'
self.assertEqual(base, result.url) self.assertEqual(base, result.url)
uri = '//www.google.com/images/srpr/logo4w.png'
quoted_uri = urllib.parse.quote(uri, safe='')
with self.settings(THUMBOR_URL=''):
result = self.client_get("/thumbnail?url=%s&size=full" % (quoted_uri,))
self.assertEqual(result.status_code, 302, result)
base = 'https://external-content.zulipcdn.net/external_content/676530cf4b101d56f56cc4a37c6ef4d4fd9b0c03/2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67'
self.assertEqual(base, result.url)
def test_with_different_THUMBOR_URL(self) -> None: def test_with_different_THUMBOR_URL(self) -> None:
self.login(self.example_email("hamlet")) self.login(self.example_email("hamlet"))
fp = StringIO("zulip!") fp = StringIO("zulip!")