1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 15:47:32 +00:00

Merge branch '370-confidential-issue-ssrf' into 'develop'

Resolve "SSRF in file upload file URL"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-03-16 16:24:47 +00:00
commit 4ab8b3eeba
9 changed files with 104 additions and 27 deletions
backend
requirements
src/baserow
api/user_files
core/user_files
tests/baserow
changelog.md
web-frontend/modules/core/plugins

View file

@ -17,3 +17,4 @@ Pillow==8.0.1
channels==3.0.3
channels-redis==3.2.0
celery[redis]==5.0.5
advocate==1.0.0

View file

@ -18,6 +18,11 @@ ERROR_FILE_URL_COULD_NOT_BE_REACHED = (
HTTP_400_BAD_REQUEST,
'The provided URL could not be reached.'
)
ERROR_INVALID_FILE_URL = (
'ERROR_INVALID_FILE_URL',
HTTP_400_BAD_REQUEST,
'The provided URL is not valid.'
)
ERROR_INVALID_USER_FILE_NAME_ERROR = (
'ERROR_INVALID_USER_FILE_NAME_ERROR',
HTTP_400_BAD_REQUEST,

View file

@ -11,13 +11,15 @@ from rest_framework.permissions import IsAuthenticated
from baserow.api.decorators import map_exceptions, validate_body
from baserow.api.schemas import get_error_schema
from baserow.core.user_files.exceptions import (
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached,
InvalidFileURLError
)
from baserow.core.user_files.handler import UserFileHandler
from .serializers import UserFileSerializer, UserFileUploadViaURLRequestSerializer
from .errors import (
ERROR_INVALID_FILE, ERROR_FILE_SIZE_TOO_LARGE, ERROR_FILE_URL_COULD_NOT_BE_REACHED
ERROR_INVALID_FILE, ERROR_FILE_SIZE_TOO_LARGE, ERROR_FILE_URL_COULD_NOT_BE_REACHED,
ERROR_INVALID_FILE_URL
)
@ -70,7 +72,8 @@ class UploadViaURLView(APIView):
400: get_error_schema([
'ERROR_INVALID_FILE',
'ERROR_FILE_SIZE_TOO_LARGE',
'ERROR_FILE_URL_COULD_NOT_BE_REACHED'
'ERROR_FILE_URL_COULD_NOT_BE_REACHED',
'ERROR_INVALID_FILE_URL'
])
}
)
@ -78,7 +81,8 @@ class UploadViaURLView(APIView):
@map_exceptions({
InvalidFileStreamError: ERROR_INVALID_FILE,
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE,
FileURLCouldNotBeReached: ERROR_FILE_URL_COULD_NOT_BE_REACHED
FileURLCouldNotBeReached: ERROR_FILE_URL_COULD_NOT_BE_REACHED,
InvalidFileURLError: ERROR_INVALID_FILE_URL
})
@validate_body(UserFileUploadViaURLRequestSerializer)
def post(self, request, data):

View file

@ -14,7 +14,14 @@ class FileSizeTooLargeError(Exception):
class FileURLCouldNotBeReached(Exception):
"""Raised when the provided URL could not be reached."""
"""
Raised when the provided URL could not be reached or points to an internal
service.
"""
class InvalidFileURLError(Exception):
"""Raised when the provided file URL is invalid."""
class InvalidUserFileNameError(Exception):

View file

@ -1,9 +1,12 @@
import pathlib
import mimetypes
from os.path import join
from io import BytesIO
from urllib.parse import urlparse
import requests
import advocate
from advocate.exceptions import UnacceptableAddressException
from requests.exceptions import RequestException
from PIL import Image, ImageOps
@ -16,7 +19,7 @@ from baserow.core.utils import sha256_hash, stream_size, random_string, truncate
from .exceptions import (
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached,
MaximumUniqueTriesError
MaximumUniqueTriesError, InvalidFileURLError
)
from .models import UserFile
@ -241,15 +244,21 @@ class UserFileHandler:
:param storage: The storage where the file must be saved to.
:type storage: Storage
:raises FileURLCouldNotBeReached: If the file could not be downloaded from
the URL.
the URL or if it points to an internal service.
:raises InvalidFileURLError: If the provided file url is invalid.
:return: The newly created user file.
:rtype: UserFile
"""
parsed_url = urlparse(url)
if parsed_url.scheme not in ['http', 'https']:
raise InvalidFileURLError('Only http and https are allowed.')
file_name = url.split('/')[-1]
try:
response = requests.get(url, stream=True, timeout=10)
response = advocate.get(url, stream=True, timeout=10)
if not response.ok:
raise FileURLCouldNotBeReached('The response did not respond with an '
@ -259,7 +268,7 @@ class UserFileHandler:
settings.USER_FILE_SIZE_LIMIT + 1,
decode_content=True
)
except RequestException:
except (RequestException, UnacceptableAddressException):
raise FileURLCouldNotBeReached('The provided URL could not be reached.')
file = SimpleUploadedFile(file_name, content)

View file

@ -167,12 +167,21 @@ def test_upload_file_via_url(api_client, data_fixture, tmpdir):
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'http://localhost/test2.txt'},
data={'url': 'https://baserow.io/test2.txt'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_FILE_URL_COULD_NOT_BE_REACHED'
# Only the http and https protocol are allowed.
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'ftp://baserow.io/test2.txt'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_INVALID_FILE_URL'
responses.add(
responses.GET,
'http://localhost/test.txt',
@ -215,3 +224,19 @@ def test_upload_file_via_url(api_client, data_fixture, tmpdir):
user_file = UserFile.objects.all().last()
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
@pytest.mark.django_db
def test_upload_file_via_url_within_private_network(api_client, data_fixture, tmpdir):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1'
)
# Could not be reached because it is an internal private URL.
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'https://localhost/test2.txt'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_FILE_URL_COULD_NOT_BE_REACHED'

View file

@ -13,7 +13,7 @@ from django.core.files.storage import FileSystemStorage
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import (
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached,
MaximumUniqueTriesError
MaximumUniqueTriesError, InvalidFileURLError
)
from baserow.core.user_files.handler import UserFileHandler
@ -263,7 +263,7 @@ def test_upload_user_file_by_url(data_fixture, tmpdir):
responses.add(
responses.GET,
'http://localhost/test.txt',
'https://baserow.io/test.txt',
body=b'Hello World',
status=200,
content_type="text/plain",
@ -272,31 +272,30 @@ def test_upload_user_file_by_url(data_fixture, tmpdir):
responses.add(
responses.GET,
'http://localhost/not-found.pdf',
body=b'Hello World',
'https://baserow.io/not-found.pdf',
status=404,
content_type="application/pdf",
stream=True,
)
# Could not be reached because it it responds with a 404
with pytest.raises(FileURLCouldNotBeReached):
handler.upload_user_file_by_url(
user,
'http://localhost/test2.txt',
'https://baserow.io/not-found.pdf',
storage=storage
)
# Only the http and https protocol are supported.
with pytest.raises(InvalidFileURLError):
handler.upload_user_file_by_url(
user,
'ftp://baserow.io/not-found.pdf',
storage=storage
)
with freeze_time('2020-01-01 12:00'):
user_file = handler.upload_user_file_by_url(
user,
'http://localhost/test.txt',
storage=storage
)
with pytest.raises(FileURLCouldNotBeReached):
handler.upload_user_file_by_url(
user,
'http://localhost/not-found.pdf',
'https://baserow.io/test.txt',
storage=storage
)
@ -318,3 +317,26 @@ def test_upload_user_file_by_url(data_fixture, tmpdir):
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
assert file_path.open().read() == 'Hello World'
@pytest.mark.django_db
def test_upload_user_file_by_url_within_private_network(data_fixture, tmpdir):
user = data_fixture.create_user()
storage = FileSystemStorage(location=str(tmpdir), base_url='http://localhost')
handler = UserFileHandler()
# Could not be reached because it is an internal private URL.
with pytest.raises(FileURLCouldNotBeReached):
handler.upload_user_file_by_url(
user,
'http://localhost/test.txt',
storage=storage
)
with pytest.raises(FileURLCouldNotBeReached):
handler.upload_user_file_by_url(
user,
'http://192.168.1.1/test.txt',
storage=storage
)

View file

@ -17,7 +17,7 @@
* Show an error to the user when the web socket connection could not be made and the
reconnect loop stops.
* Fixed 100X backend web socket errors when refreshing the page.
* Prevented the date field value being negative.
* Fixed SSRF bug in the file upload by URL by blocking urls to the private network.
## Released (2021-03-01)

View file

@ -52,6 +52,10 @@ class ErrorHandler {
'Invalid URL',
'The provided file URL could not be reached.'
),
ERROR_INVALID_FILE_URL: new ResponseErrorMessage(
'Invalid URL',
'The provided file URL is invalid or not allowed.'
),
}
// A temporary notFoundMap containing the error messages for when the