thumbnail: Do not Camo old thumbor URLs; serve images directly.

Providing a signed Camo URL for arbitrary URLs opened the server up to
being an open redirector.  Return 403 if the URL is not a user upload,
and the backend image if it is.  Since we do not have ImageAttachment
rows for uploads at a time we wrote `/thumbnail?` URLs, return the
full-size content.
This commit is contained in:
Alex Vandiver
2024-07-24 20:35:18 +00:00
committed by Tim Abbott
parent a7b304d61d
commit c726d2ec01
4 changed files with 40 additions and 60 deletions

View File

@@ -5,15 +5,12 @@ from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypeVar from typing import TypeVar
from urllib.parse import urljoin
import pyvips import pyvips
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from typing_extensions import override from typing_extensions import override
from zerver.lib.camo import get_camo_url
from zerver.lib.exceptions import ErrorCode, JsonableError from zerver.lib.exceptions import ErrorCode, JsonableError
from zerver.lib.queue import queue_event_on_commit from zerver.lib.queue import queue_event_on_commit
from zerver.models import AbstractAttachment, ImageAttachment from zerver.models import AbstractAttachment, ImageAttachment
@@ -139,14 +136,6 @@ class BadImageError(JsonableError):
code = ErrorCode.BAD_IMAGE code = ErrorCode.BAD_IMAGE
def generate_thumbnail_url(path: str, size: str = "0x0") -> str:
path = urljoin("/", path)
if url_has_allowed_host_and_scheme(path, allowed_hosts=None):
return path
return get_camo_url(path)
@contextmanager @contextmanager
def libvips_check_image(image_data: bytes) -> Iterator[pyvips.Image]: def libvips_check_image(image_data: bytes) -> Iterator[pyvips.Image]:
# The primary goal of this is to verify that the image is valid, # The primary goal of this is to verify that the image is valid,

View File

@@ -51,8 +51,8 @@ class ThumbnailRedirectEndpointTest(ZulipTestCase):
self.assertEqual(base, url[: len(base)]) self.assertEqual(base, url[: len(base)])
result = self.client_get("/thumbnail", {"url": url[1:], "size": "full"}) result = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
self.assertEqual(result.status_code, 302, result) self.assertEqual(result.status_code, 200)
self.assertEqual(url, result["Location"]) self.assertEqual(result.getvalue(), b"zulip!")
self.login("iago") self.login("iago")
result = self.client_get("/thumbnail", {"url": url[1:], "size": "full"}) result = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
@@ -62,21 +62,15 @@ class ThumbnailRedirectEndpointTest(ZulipTestCase):
def test_thumbnail_external_redirect(self) -> None: def test_thumbnail_external_redirect(self) -> None:
url = "https://www.google.com/images/srpr/logo4w.png" url = "https://www.google.com/images/srpr/logo4w.png"
result = self.client_get("/thumbnail", {"url": url, "size": "full"}) result = self.client_get("/thumbnail", {"url": url, "size": "full"})
self.assertEqual(result.status_code, 302, result) self.assertEqual(result.status_code, 403)
base = "https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67"
self.assertEqual(base, result["Location"])
url = "http://www.google.com/images/srpr/logo4w.png" url = "http://www.google.com/images/srpr/logo4w.png"
result = self.client_get("/thumbnail", {"url": url, "size": "full"}) result = self.client_get("/thumbnail", {"url": url, "size": "full"})
self.assertEqual(result.status_code, 302, result) self.assertEqual(result.status_code, 403)
base = "https://external-content.zulipcdn.net/external_content/7b6552b60c635e41e8f6daeb36d88afc4eabde79/687474703a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67"
self.assertEqual(base, result["Location"])
url = "//www.google.com/images/srpr/logo4w.png" url = "//www.google.com/images/srpr/logo4w.png"
result = self.client_get("/thumbnail", {"url": url, "size": "full"}) result = self.client_get("/thumbnail", {"url": url, "size": "full"})
self.assertEqual(result.status_code, 302, result) self.assertEqual(result.status_code, 403)
base = "https://external-content.zulipcdn.net/external_content/676530cf4b101d56f56cc4a37c6ef4d4fd9b0c03/2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67"
self.assertEqual(base, result["Location"])
@override_settings(RATE_LIMITING=True) @override_settings(RATE_LIMITING=True)
def test_thumbnail_redirect_for_spectator(self) -> None: def test_thumbnail_redirect_for_spectator(self) -> None:
@@ -99,7 +93,8 @@ class ThumbnailRedirectEndpointTest(ZulipTestCase):
self.logout() self.logout()
response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"}) response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/accounts/login/?next="))
# Allow file access for web-public stream # Allow file access for web-public stream
self.login("hamlet") self.login("hamlet")
@@ -110,12 +105,13 @@ class ThumbnailRedirectEndpointTest(ZulipTestCase):
self.logout() self.logout()
response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"}) response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 200)
# Deny file access since rate limited # Deny file access since rate limited
with ratelimit_rule(86400, 0, domain="spectator_attachment_access_by_file"): with ratelimit_rule(86400, 0, domain="spectator_attachment_access_by_file"):
response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"}) response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/accounts/login/?next="))
# Deny random file access # Deny random file access
response = self.client_get( response = self.client_get(
@@ -125,7 +121,7 @@ class ThumbnailRedirectEndpointTest(ZulipTestCase):
"size": "full", "size": "full",
}, },
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 404)
class ThumbnailEmojiTest(ZulipTestCase): class ThumbnailEmojiTest(ZulipTestCase):

View File

@@ -1,8 +1,10 @@
from urllib.parse import urljoin
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden from django.http import HttpRequest, HttpResponse, HttpResponseForbidden
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme
from zerver.lib.camo import is_camo_url_valid from zerver.lib.camo import is_camo_url_valid
from zerver.lib.thumbnail import generate_thumbnail_url
def handle_camo_url( def handle_camo_url(
@@ -10,6 +12,9 @@ def handle_camo_url(
) -> HttpResponse: # nocoverage ) -> HttpResponse: # nocoverage
original_url = bytes.fromhex(received_url).decode() original_url = bytes.fromhex(received_url).decode()
if is_camo_url_valid(digest, original_url): if is_camo_url_valid(digest, original_url):
return redirect(generate_thumbnail_url(original_url)) original_url = urljoin("/", original_url)
if url_has_allowed_host_and_scheme(original_url, allowed_hosts=None):
return redirect(original_url)
return HttpResponseForbidden("<p>Not a valid URL.</p>")
else: else:
return HttpResponseForbidden("<p>Not a valid URL.</p>") return HttpResponseForbidden("<p>Not a valid URL.</p>")

View File

@@ -1,28 +1,11 @@
import re
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden from django.http import HttpRequest, HttpResponseBase, HttpResponseForbidden
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from zerver.context_processors import get_valid_realm_from_request
from zerver.lib.attachments import validate_attachment_request
from zerver.lib.thumbnail import generate_thumbnail_url
from zerver.lib.typed_endpoint import typed_endpoint from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import Realm, UserProfile from zerver.models import UserProfile
from zerver.views.upload import serve_file
def validate_thumbnail_request(
realm: Realm,
maybe_user_profile: UserProfile | AnonymousUser,
path: str,
) -> bool | None:
# path here does not have a leading / as it is parsed from request hitting the
# thumbnail endpoint (defined in urls.py) that way.
if path.startswith("user_uploads/"):
path_id = path[len("user_uploads/") :]
return validate_attachment_request(maybe_user_profile, path_id, realm)
# This is an external link and we don't enforce restricted view policy here.
return True
@typed_endpoint @typed_endpoint
@@ -32,15 +15,22 @@ def backend_serve_thumbnail(
*, *,
url: str, url: str,
size: str, size: str,
) -> HttpResponse: ) -> HttpResponseBase:
if not maybe_user_profile.is_authenticated: # This URL used to be passed arbitrary URLs, and pass them through
realm = get_valid_realm_from_request(request) # Camo; we no longer support doing so, and instead return a 403.
else: #
assert isinstance(maybe_user_profile, UserProfile) # Modern thumbnailing uses URLs of the style
realm = maybe_user_profile.realm # `/user_uploads/thumbnail/.../300x200.webp`; this endpoint is
# kept for backward compatibility, and for future extension for
# thumbnailing external URLs.
upload_path_parts = re.match(r"user_uploads/(\d+)/(.*)", url)
if not upload_path_parts:
return HttpResponseForbidden()
if not validate_thumbnail_request(realm, maybe_user_profile, url): realm_id_str = upload_path_parts[1]
return HttpResponseForbidden(_("<p>You are not authorized to view this file.</p>")) path_id = upload_path_parts[2]
thumbnail_url = generate_thumbnail_url(url) # We do not have ImageAttachment rows for historical uploads, so
return redirect(thumbnail_url) # we cannot serve a "new" thumbnail for these requests; serve the
# full-size file.
return serve_file(request, maybe_user_profile, realm_id_str, path_id)