Commit 44b788ea authored by Thom Wiggers's avatar Thom Wiggers

Merge branch 'cherry-pick-b63243c0' into 'release/1.6'

[HOTFIX] Fix path traversal

See merge request !436
parents 0bc169dd 8a5150ef
......@@ -17,7 +17,7 @@ Getting started
If you use Docker, please look at [this part](#docker) of the README.
0. Get at least Python 3.4 and install the Pillow requirements as per below.
0. Get at least Python 3.5 and install the Pillow requirements as per below.
1. Clone this repository
2. Run `source ./source_me.sh` (or use your own favourite virtualenv solution)
3. Run `pip install -r requirements.txt`
......
......@@ -174,3 +174,8 @@ texinfo_documents = [
# Default flags for autodoc, saves typing
autodoc_default_flags = ['members', 'undoc-members']
# -- Options for doctest --------------------------------------------------
# Disable doctests in normal strings
doctest_test_doctest_blocks = ''
......@@ -25,6 +25,14 @@ thaliawebsite.admin module
:undoc-members:
:show-inheritance:
thaliawebsite.forms module
--------------------------
.. automodule:: thaliawebsite.forms
:members:
:undoc-members:
:show-inheritance:
thaliawebsite.menus module
--------------------------
......
import doctest
from . import views
def load_tests(loader, tests, ignore):
"""
Load all tests in this module
"""
# Adds the doctests in views
tests.addTests(doctest.DocTestSuite(views))
return tests
import os
from django.core.exceptions import SuspiciousFileOperation
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
......@@ -10,7 +11,6 @@ from sendfile import sendfile
from zipfile import ZipFile
from tempfile import gettempdir
from utils.snippets import sanitize_path
from utils.views import _private_thumbnails_unauthed
from .models import Album
......@@ -107,10 +107,27 @@ def shared_album(request, slug, token):
return _render_album_page(request, album)
def _download(request, path):
"""This function provides a layer of indirection for shared albums"""
path = sanitize_path(path)
path = os.path.join(settings.MEDIA_ROOT, 'photos', *path.split('/')[1:])
def _download(request, original_path):
"""This function provides a layer of indirection for shared albums
Checks for some path traversal:
>>> from django.test import RequestFactory
>>> r = RequestFactory().get('/photos/download/../../../../../etc/passwd')
>>> _download(r, '../../../../../../../etc/passwd') #doctest: +ELLIPSIS
Traceback (most recent call last):
...
django.core.exceptions.SuspiciousFileOperation: ...
"""
photopath = os.path.join(settings.MEDIA_ROOT, 'photos')
path = os.path.normpath(
os.path.join(photopath, *original_path.split('/')[1:]))
if not os.path.commonpath([photopath, path]) == photopath:
raise SuspiciousFileOperation(
"Path traversal detected: someone tried to download "
"{}, input: {}".format(path, original_path))
if not os.path.isfile(path):
raise Http404("Photo not found.")
return sendfile(request, path, attachment=True)
......
......@@ -69,7 +69,7 @@ def wiki_login(request):
def styleguide_file(request, filename):
path = os.path.join(settings.MEDIA_ROOT, 'styleguide')
filepath = os.path.join(path, filename)
if not (os.path.commonprefix([path, filepath]).startswith(path) and
if not (os.path.commonpath([path, filepath]) == path and
os.path.isfile(filepath)):
raise Http404("File not found.")
return sendfile(request, filepath, attachment=True)
......
import os
from django.utils import timezone
from django.utils.six.moves.urllib.parse import unquote
def datetime_to_lectureyear(date):
"""Convert a date to the start of the lectureyear
>>> from datetime import date, datetime, timezone
>>> nov_23 = date(1990, 11, 7)
>>> datetime_to_lectureyear(nov_23)
1990
>>> mar_2 = date(1993, 3, 2)
>>> datetime_to_lectureyear(mar_2)
1992
Also works on ``datetimes``, but they need to be tz-aware:
>>> new_year = datetime(2000, 1, 1, tzinfo=timezone.utc)
>>> datetime_to_lectureyear(new_year)
1999
"""
if isinstance(date, timezone.datetime):
date = timezone.localtime(date).date()
sept_1 = timezone.make_aware(timezone.datetime(date.year, 9, 1))
if date < sept_1.date():
return date.year - 1
return date.year
def sanitize_path(path):
"""Cleans up an insecure path, i.e. against directory traversal.
This code is partially copied from django.views.static"""
path = os.path.normpath(unquote(path))
path = path.lstrip('/')
newpath = ''
for part in path.split('/'):
if not part:
# Strip empty path components.
continue
drive, part = os.path.splitdrive(part)
head, part = os.path.split(part)
if part in (os.curdir, os.pardir):
# Strip '.' and '..' in path.
continue
newpath = os.path.join(newpath, part).replace('\\', '/')
return newpath
import doctest
from django.core.exceptions import FieldError
from django.db import models
from django.test import TestCase, override_settings
from django.utils import translation
from utils.translation import ModelTranslateMeta, MultilingualField
from utils import snippets, validators
LANGUAGES = [
('en', 'English'),
......@@ -12,6 +15,17 @@ LANGUAGES = [
]
def load_tests(loader, tests, ignore):
"""
Load all tests in this module
"""
# Adds the doctests in snippets
tests.addTests(doctest.DocTestSuite(snippets))
# Adds the doctests in validators
tests.addTests(doctest.DocTestSuite(validators))
return tests
@override_settings(LANGUAGES=LANGUAGES)
class TestTranslateMeta(TestCase):
......
......@@ -3,7 +3,24 @@ import os
from django.core.exceptions import ValidationError
def validate_file_extension(file, exts=['.txt', '.pdf', '.jpg', '.png']):
def validate_file_extension(file,
exts=['.txt', '.pdf', '.jpg', '.jpeg', '.png']):
"""
Checks if a file has a certain allowed extension. Raises a
``ValidationError`` if that's not the case.
>>> class File(object):
... pass
>>> f = File()
>>> f.name = 'foo.jpeg'
>>> validate_file_extension(f)
>>> f.name = 'foo.exe'
>>> validate_file_extension(f)
Traceback (most recent call last):
...
django.core.exceptions.ValidationError: ['File extension not allowed.']
>>> validate_file_extension(f, ['.exe'])
"""
ext = os.path.splitext(file.name)[1]
if not ext.lower() in exts:
raise ValidationError("File extension not allowed.")
import os
from PIL import Image, ImageOps
from django.core.exceptions import SuspiciousFileOperation
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import Http404
......@@ -9,15 +10,21 @@ from django.urls import reverse
from django.utils.http import urlunquote
from sendfile import sendfile
from .snippets import sanitize_path
def _private_thumbnails_unauthed(request, size_fit, original_path):
"""
Serve thumbnails from the filesystem
def _private_thumbnails_unauthed(request, size_fit, path):
"""This layer of indirection makes it possible to make exceptions
This layer of indirection makes it possible to make exceptions
to the authentication requirements for thumbnails, e.g. when sharing
photo albums with external parties using access tokens."""
path = sanitize_path(path)
path = os.path.join(settings.MEDIA_ROOT, 'thumbnails', size_fit, path)
photo albums with external parties using access tokens.
"""
thumbpath = os.path.join(settings.MEDIA_ROOT, 'thumbnails', size_fit)
path = os.path.normpath(os.path.join(thumbpath, original_path))
if not os.path.commonpath([thumbpath, path]) == thumbpath:
raise SuspiciousFileOperation(
"Path traversal detected: someone tried to download "
"{}, input: {}".format(path, original_path))
if not os.path.isfile(path):
raise Http404("Thumbnail not found.")
return sendfile(request, path)
......@@ -29,17 +36,36 @@ def private_thumbnails(request, size_fit, path):
def generate_thumbnail(request, size_fit, path, thumbpath):
"""The thumbnails are generated with this route. Because the
"""
Generate thumbnail and redirect user to new location
The thumbnails are generated with this route. Because the
thumbnails will be generated in parallel, it will not block
page load when many thumbnails need to be generated.
After it is done, the user is redirected to the new location
of the thumbnail."""
thumbpath = urlunquote(thumbpath)
path = urlunquote(path)
full_thumbpath = os.path.join(settings.MEDIA_ROOT, thumbpath)
full_path = os.path.join(settings.MEDIA_ROOT, path)
full_thumbpath = os.path.normpath(
os.path.join(settings.MEDIA_ROOT, thumbpath))
full_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, path))
size, fit = size_fit.split('_')
public_media = os.path.join(settings.MEDIA_ROOT, 'public')
thumb_root = os.path.join(settings.MEDIA_ROOT, 'thumbnails', size_fit)
public_img = False
if (os.path.commonpath([full_thumbpath, full_path, public_media]) ==
public_media):
public_img = True
elif not (os.path.commonpath([full_thumbpath, thumb_root]) ==
thumb_root and
os.path.commonpath([full_path, settings.MEDIA_ROOT]) ==
settings.MEDIA_ROOT):
raise SuspiciousFileOperation(
"Path traversal detected: someone tried to generate a thumb from "
"{} to {}".format(full_path, full_thumbpath))
os.makedirs(os.path.dirname(full_thumbpath), exist_ok=True)
if (not os.path.isfile(full_thumbpath) or
os.path.getmtime(full_path) > os.path.getmtime(full_thumbpath)):
......@@ -51,12 +77,11 @@ def generate_thumbnail(request, size_fit, path, thumbpath):
thumb = ImageOps.fit(image, size, Image.ANTIALIAS)
thumb.save(full_thumbpath)
# We can instantly redirect to the new correct image url
if path.split('/')[0] == 'public':
# We can instantly redirect to the new correct image url if public
if public_img:
return redirect(settings.MEDIA_URL + thumbpath, permanent=True)
# Otherwise redirect to the route with an auth check
return redirect(
reverse('private-thumbnails', args=[size_fit, path]),
permanent=True
)
permanent=True)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment