CVE-2022-23071: Server-Side Request Forgery (SSRF) (CWE-918)
value: In Recipes, versions 0.9.1 through 1.2.5 are vulnerable to Server Side Request Forgery (SSRF), in the “Import Recipe” functionality. When an attacker enters the localhost URL, a low privileged attacker can access/read the internal file system to access sensitive information.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 6.5
Published: 2022-06-19T11:15Z
Repository: https://github.com/TandoorRecipes/recipes
Language: Python
Files changed: cookbook/integration/cookbookapp.py, cookbook/integration/cookmate.py, cookbook/integration/recettetek.py, cookbook/integration/recipesage.py, cookbook/provider/dropbox.py, cookbook/provider/nextcloud.py, cookbook/views/api.py, requirements.txt
Commit date: 2022-05-17 18:04:43 +0200
Commit message: added url validation to all server requests

Diff: diff --git a/cookbook/integration/cookbookapp.py b/cookbook/integration/cookbookapp.py
index 898887d3..f22e9d45 100644
--- a/cookbook/integration/cookbookapp.py
+++ b/cookbook/integration/cookbookapp.py
@@ -4,10 +4,11 @@ import json
 import re
 from gettext import gettext as _
 from io import BytesIO
 
 import requests
+import validators
 import yaml
 
 from cookbook.helper.ingredient_parser import IngredientParser
 from cookbook.helper.recipe_html_import import get_recipe_from_source
 from cookbook.helper.recipe_url_import import iso_duration_to_minutes
@@ -57,12 +58,14 @@ class CookBookApp(Integration):
                 food=f, unit=u, amount=ingredient['amount'], note=ingredient['note'],  space=self.request.space,
             ))
 
         if len(images) > 0:
             try:
-                response = requests.get(images[0])
-                self.import_recipe_image(recipe, BytesIO(response.content))
+                url = images[0]
+                if validators.url(url, public=True):
+                    response = requests.get(url)
+                    self.import_recipe_image(recipe, BytesIO(response.content))
             except Exception as e:
                 print('failed to import image ', str(e))
 
         recipe.save()
         return recipe
diff --git a/cookbook/integration/cookmate.py b/cookbook/integration/cookmate.py
index fa1e9ce2..e804180a 100644
--- a/cookbook/integration/cookmate.py
+++ b/cookbook/integration/cookmate.py
@@ -3,10 +3,11 @@ import json
 from io import BytesIO
 
 from gettext import gettext as _
 
 import requests
+import validators
 from lxml import etree
 
 from cookbook.helper.ingredient_parser import IngredientParser
 from cookbook.helper.recipe_url_import import parse_servings, parse_time, parse_servings_text
 from cookbook.integration.integration import Integration
@@ -62,11 +63,13 @@ class Cookmate(Integration):
                     food=f, unit=u, amount=amount, note=note, original_text=ingredient.text.strip(), space=self.request.space,
                 ))
 
         if recipe_xml.find('imageurl') is not None:
             try:
-                response = requests.get(recipe_xml.find('imageurl').text.strip())
+                url = recipe_xml.find('imageurl').text.strip()
+                if validators.url(url, public=True):
+                    response = requests.get(url)
                 self.import_recipe_image(recipe, BytesIO(response.content))
             except Exception as e:
                 print('failed to import image ', str(e))
 
         recipe.save()
diff --git a/cookbook/integration/recettetek.py b/cookbook/integration/recettetek.py
index 7eb93a8e..c774b3d5 100644
--- a/cookbook/integration/recettetek.py
+++ b/cookbook/integration/recettetek.py
@@ -3,10 +3,11 @@ import json
 import re
 from io import BytesIO
 from zipfile import ZipFile
 
 import requests
+import validators
 
 from django.utils.translation import gettext as _
 from cookbook.helper.image_processing import get_filetype
 from cookbook.helper.ingredient_parser import IngredientParser
 from cookbook.integration.integration import Integration
@@ -121,15 +122,17 @@ class RecetteTek(Integration):
                     if '.rtk' in f['name']:
                         import_zip = ZipFile(f['file'])
                         self.import_recipe_image(recipe, BytesIO(import_zip.read(image_file_name)), filetype=get_filetype(image_file_name))
             else:
                 if file['originalPicture'] != '':
-                    response = requests.get(file['originalPicture'])
-                    if imghdr.what(BytesIO(response.content)) is not None:
-                        self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
-                    else:
-                        raise Exception("Original image failed to download.")
+                    url = file['originalPicture']
+                    if validators.url(url, public=True):
+                        response = requests.get(url)
+                        if imghdr.what(BytesIO(response.content)) is not None:
+                            self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
+                        else:
+                            raise Exception("Original image failed to download.")
         except Exception as e:
             print(recipe.name, ': failed to import image ', str(e))
 
         return recipe
 
diff --git a/cookbook/integration/recipesage.py b/cookbook/integration/recipesage.py
index 29bf0eb4..149c1c5d 100644
--- a/cookbook/integration/recipesage.py
+++ b/cookbook/integration/recipesage.py
@@ -1,9 +1,10 @@
 import json
 from io import BytesIO
 
 import requests
+import validators
 
 from cookbook.helper.ingredient_parser import IngredientParser
 from cookbook.integration.integration import Integration
 from cookbook.models import Ingredient, Recipe, Step
 
@@ -49,12 +50,14 @@ class RecipeSage(Integration):
                     ))
             recipe.steps.add(step)
 
         if len(file['image']) > 0:
             try:
-                response = requests.get(file['image'][0])
-                self.import_recipe_image(recipe, BytesIO(response.content))
+                url = file['image'][0]
+                if validators.url(url, public=True):
+                    response = requests.get(url)
+                    self.import_recipe_image(recipe, BytesIO(response.content))
             except Exception as e:
                 print('failed to import image ', str(e))
 
         return recipe
 
diff --git a/cookbook/provider/dropbox.py b/cookbook/provider/dropbox.py
index ca6994b9..7d8dcc75 100644
--- a/cookbook/provider/dropbox.py
+++ b/cookbook/provider/dropbox.py
@@ -2,10 +2,12 @@ import io
 import json
 import os
 from datetime import datetime
 
 import requests
+import validators
+
 from cookbook.models import Recipe, RecipeImport, SyncLog
 from cookbook.provider.provider import Provider
 
 
 class Dropbox(Provider):
@@ -102,13 +104,15 @@ class Dropbox(Provider):
     def get_file(recipe):
         if not recipe.link:
             recipe.link = Dropbox.get_share_link(recipe)
             recipe.save()
 
-        response = requests.get(recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.'))
+        url = recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')
+        if validators.url(url, public=True):
+            response = requests.get(url)
 
-        return io.BytesIO(response.content)
+            return io.BytesIO(response.content)
 
     @staticmethod
     def rename_file(recipe, new_name):
         url = "https://api.dropboxapi.com/2/files/move_v2"
 
diff --git a/cookbook/provider/nextcloud.py b/cookbook/provider/nextcloud.py
index e7b100df..9399e104 100644
--- a/cookbook/provider/nextcloud.py
+++ b/cookbook/provider/nextcloud.py
@@ -2,10 +2,11 @@ import io
 import os
 import tempfile
 from datetime import datetime
 
 import requests
+import validators
 import webdav3.client as wc
 from cookbook.models import Recipe, RecipeImport, SyncLog
 from cookbook.provider.provider import Provider
 from requests.auth import HTTPBasicAuth
 
@@ -90,24 +91,25 @@ class Nextcloud(Provider):
         headers = {
             "OCS-APIRequest": "true",
             "Content-Type": "application/json"
         }
 
-        r = requests.get(
-            url,
-            headers=headers,
-            auth=HTTPBasicAuth(
-                recipe.storage.username, recipe.storage.password
+        if validators.url(url, public=True):
+            r = requests.get(
+                url,
+                headers=headers,
+                auth=HTTPBasicAuth(
+                    recipe.storage.username, recipe.storage.password
+                )
             )
-        )
 
-        response_json = r.json()
-        for element in response_json['ocs']['data']:
-            if element['share_type'] == '3':
-                return element['url']
+            response_json = r.json()
+            for element in response_json['ocs']['data']:
+                if element['share_type'] == '3':
+                    return element['url']
 
-        return Nextcloud.create_share_link(recipe)
+            return Nextcloud.create_share_link(recipe)
 
     @staticmethod
     def get_file(recipe):
         client = Nextcloud.get_client(recipe.storage)
 
diff --git a/cookbook/views/api.py b/cookbook/views/api.py
index b07aaf35..3d1ec493 100644
--- a/cookbook/views/api.py
+++ b/cookbook/views/api.py
@@ -4,48 +4,48 @@ import mimetypes
 import re
 import uuid
 from collections import OrderedDict
 
 import requests
+import validators
 from PIL import UnidentifiedImageError
 from annoying.decorators import ajax_request
 from annoying.functions import get_object_or_None
 from django.contrib import messages
 from django.contrib.auth.models import User
 from django.contrib.postgres.search import TrigramSimilarity
 from django.core.exceptions import FieldError, ValidationError
 from django.core.files import File
-from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, ProtectedError, Q,
+from django.db.models import (Case, Count, Exists, OuterRef, ProtectedError, Q,
                               Subquery, Value, When)
 from django.db.models.fields.related import ForeignObjectRel
 from django.db.models.functions import Coalesce, Lower
 from django.http import FileResponse, HttpResponse, JsonResponse
 from django.shortcuts import get_object_or_404, redirect
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django_scopes import scopes_disabled
 from icalendar import Calendar, Event
-from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me
 from requests.exceptions import MissingSchema
 from rest_framework import decorators, status, viewsets
 from rest_framework.exceptions import APIException, PermissionDenied
 from rest_framework.pagination import PageNumberPagination
 from rest_framework.parsers import MultiPartParser
 from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
 from rest_framework.response import Response
 from rest_framework.viewsets import ViewSetMixin
 from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
+from validators import ValidationFailure
 
 from cookbook.helper.HelperFunctions import str2bool
 from cookbook.helper.image_processing import handle_image
 from cookbook.helper.ingredient_parser import IngredientParser
 from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner,
                                                CustomIsShare, CustomIsShared, CustomIsUser,
                                                group_required)
 from cookbook.helper.recipe_html_import import get_recipe_from_source
 from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
-from cookbook.helper.recipe_url_import import get_from_scraper
 from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
 from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
                              FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
                              Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
                              ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket,
@@ -772,20 +772,22 @@ class RecipeViewSet(viewsets.ModelViewSet):
         serializer = self.serializer_class(obj, data=request.data, partial=True)
 
         if serializer.is_valid():
             serializer.save()
             image = None
-            filetype = ".jpeg" # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways
+            filetype = ".jpeg"  # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways
 
             if 'image' in serializer.validated_data:
                 image = obj.image
                 filetype = mimetypes.guess_extension(serializer.validated_data['image'].content_type) or filetype
             elif 'image_url' in serializer.validated_data:
                 try:
-                    response = requests.get(serializer.validated_data['image_url'])
-                    image = File(io.BytesIO(response.content))
-                    filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
+                    url = serializer.validated_data['image_url']
+                    if validators.url(url, public=True):
+                        response = requests.get(url)
+                        image = File(io.BytesIO(response.content))
+                        filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
                 except UnidentifiedImageError as e:
                     print(e)
                     pass
                 except MissingSchema as e:
                     print(e)
@@ -1186,21 +1188,28 @@ def recipe_from_source(request):
         }, status=400)
 
     # in manual mode request complete page to return it later
     if url:
         try:
-            data = requests.get(url, headers=external_request_headers).content
+            if validators.url(url, public=True):
+                data = requests.get(url, headers=external_request_headers).content
+            else:
+                return JsonResponse({
+                    'error': True,
+                    'msg': _('Invalid Url')
+                }, status=400)
         except requests.exceptions.ConnectionError:
             return JsonResponse({
                 'error': True,
                 'msg': _('Connection Refused.')
             }, status=400)
         except requests.exceptions.MissingSchema:
             return JsonResponse({
                 'error': True,
                 'msg': _('Bad URL Schema.')
             }, status=400)
+
     recipe_json, recipe_tree, recipe_html, recipe_images = get_recipe_from_source(data, url, request)
     if len(recipe_tree) == 0 and len(recipe_json) == 0:
         return JsonResponse({
             'error': True,
             'msg': _('No usable data could be found.')
diff --git a/requirements.txt b/requirements.txt
index 08c729fe..5b9871c8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -41,5 +41,6 @@ django-prometheus==2.2.0
 django-hCaptcha==0.2.0
 python-ldap==3.4.0
 django-auth-ldap==4.0.0
 pytest-factoryboy==2.1.0
 pyppeteer==1.0.2
+validators==0.19.0
\ No newline at end of file

Vulnerable code:                 response = requests.get(images[0])
                self.import_recipe_image(recipe, BytesIO(response.content))
                response = requests.get(recipe_xml.find('imageurl').text.strip())
                    response = requests.get(file['originalPicture'])
                    if imghdr.what(BytesIO(response.content)) is not None:
                        self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
                    else:
                        raise Exception("Original image failed to download.")
                response = requests.get(file['image'][0])
                self.import_recipe_image(recipe, BytesIO(response.content))
        response = requests.get(recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.'))
        return io.BytesIO(response.content)
        r = requests.get(
            url,
            headers=headers,
            auth=HTTPBasicAuth(
                recipe.storage.username, recipe.storage.password
        )
        response_json = r.json()
        for element in response_json['ocs']['data']:
            if element['share_type'] == '3':
                return element['url']
        return Nextcloud.create_share_link(recipe)
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, ProtectedError, Q,
from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me
from cookbook.helper.recipe_url_import import get_from_scraper
            filetype = ".jpeg" # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways
                    response = requests.get(serializer.validated_data['image_url'])
                    image = File(io.BytesIO(response.content))
                    filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
            data = requests.get(url, headers=external_request_headers).content
Fixed code: import validators
                url = images[0]
                if validators.url(url, public=True):
                    response = requests.get(url)
                    self.import_recipe_image(recipe, BytesIO(response.content))
import validators
                url = recipe_xml.find('imageurl').text.strip()
                if validators.url(url, public=True):
                    response = requests.get(url)
import validators
                    url = file['originalPicture']
                    if validators.url(url, public=True):
                        response = requests.get(url)
                        if imghdr.what(BytesIO(response.content)) is not None:
                            self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
                        else:
                            raise Exception("Original image failed to download.")
import validators
                url = file['image'][0]
                if validators.url(url, public=True):
                    response = requests.get(url)
                    self.import_recipe_image(recipe, BytesIO(response.content))
import validators

        url = recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')
        if validators.url(url, public=True):
            response = requests.get(url)
            return io.BytesIO(response.content)
import validators
        if validators.url(url, public=True):
            r = requests.get(
                url,
                headers=headers,
                auth=HTTPBasicAuth(
                    recipe.storage.username, recipe.storage.password
                )
            response_json = r.json()
            for element in response_json['ocs']['data']:
                if element['share_type'] == '3':
                    return element['url']
            return Nextcloud.create_share_link(recipe)
import validators
from django.db.models import (Case, Count, Exists, OuterRef, ProtectedError, Q,
from validators import ValidationFailure
            filetype = ".jpeg"  # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways
                    url = serializer.validated_data['image_url']
                    if validators.url(url, public=True):
                        response = requests.get(url)
                        image = File(io.BytesIO(response.content))
                        filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
            if validators.url(url, public=True):
                data = requests.get(url, headers=external_request_headers).content
            else:
                return JsonResponse({
                    'error': True,
                    'msg': _('Invalid Url')
                }, status=400)

validators==0.19.0