From e54ac3c6fd918d5bcc0acaf7c7c3b49ab03492db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A9stor=20P=C3=A9rez?= <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 28 Jul 2022 16:45:45 +1000 Subject: [PATCH] Prevent directory traversion with static files (#2495) Co-authored-by: Adam Hopkins Co-authored-by: Zhiwei Liang --- sanic/mixins/routes.py | 33 ++++++++++++------------- tests/test_static.py | 56 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 425b29e0..8a5f10cc 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -3,9 +3,8 @@ from contextlib import suppress from functools import partial, wraps from inspect import getsource, signature from mimetypes import guess_type -from os import path +from os import path, sep from pathlib import PurePath -from re import sub from textwrap import dedent from time import gmtime, strftime from typing import ( @@ -806,23 +805,23 @@ class RouteMixin(metaclass=SanicMeta): content_type=None, __file_uri__=None, ): - # Using this to determine if the URL is trying to break out of the path - # served. os.path.realpath seems to be very slow - if __file_uri__ and "../" in __file_uri__: - raise BadRequest("Invalid URL") # Merge served directory and requested file if provided - # Strip all / that in the beginning of the URL to help prevent python - # from herping a derp and treating the uri as an absolute path - root_path = file_path = file_or_directory - if __file_uri__: - file_path = path.join( - file_or_directory, sub("^[/]*", "", __file_uri__) - ) + root_path = file_path = path.abspath(unquote(file_or_directory)) - # URL decode the path sent by the browser otherwise we won't be able to - # match filenames which got encoded (filenames with spaces etc) - file_path = path.abspath(unquote(file_path)) - if not file_path.startswith(path.abspath(unquote(root_path))): + if __file_uri__: + # Strip all / that in the beginning of the URL to help prevent + # python from herping a derp and treating the uri as an + # absolute path + unquoted_file_uri = unquote(__file_uri__).lstrip("/") + + segments = unquoted_file_uri.split("/") + if ".." in segments or any(sep in segment for segment in segments): + raise BadRequest("Invalid URL") + + file_path = path.join(file_or_directory, unquoted_file_uri) + file_path = path.abspath(file_path) + + if not file_path.startswith(root_path): error_logger.exception( f"File not found: path={file_or_directory}, " f"relative_url={__file_uri__}" diff --git a/tests/test_static.py b/tests/test_static.py index 36a98e11..fd33ee03 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -1,6 +1,7 @@ import inspect import logging import os +import sys from collections import Counter from pathlib import Path @@ -8,7 +9,7 @@ from time import gmtime, strftime import pytest -from sanic import text +from sanic import Sanic, text from sanic.exceptions import FileNotFound @@ -21,6 +22,22 @@ def static_file_directory(): return static_directory +@pytest.fixture(scope="module") +def double_dotted_directory_file(static_file_directory: str): + """Generate double dotted directory and its files""" + if sys.platform == "win32": + raise Exception("Windows doesn't support double dotted directories") + + file_path = Path(static_file_directory) / "dotted.." / "dot.txt" + double_dotted_dir = file_path.parent + Path.mkdir(double_dotted_dir, exist_ok=True) + with open(file_path, "w") as f: + f.write("DOT\n") + yield file_path + Path.unlink(file_path) + Path.rmdir(double_dotted_dir) + + def get_file_path(static_file_directory, file_name): return os.path.join(static_file_directory, file_name) @@ -578,3 +595,40 @@ def test_resource_type_dir(app, static_file_directory): def test_resource_type_unknown(app, static_file_directory, caplog): with pytest.raises(ValueError): app.static("/static", static_file_directory, resource_type="unknown") + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Windows does not support double dotted directories", +) +def test_dotted_dir_ok( + app: Sanic, static_file_directory: str, double_dotted_directory_file: Path +): + app.static("/foo", static_file_directory) + double_dotted_directory_file = str(double_dotted_directory_file).lstrip( + static_file_directory + ) + _, response = app.test_client.get("/foo/" + double_dotted_directory_file) + assert response.status == 200 + assert response.body == b"DOT\n" + + +def test_breakout(app: Sanic, static_file_directory: str): + app.static("/foo", static_file_directory) + + _, response = app.test_client.get("/foo/..%2Fstatic/test.file") + assert response.status == 400 + + +@pytest.mark.skipif( + sys.platform != "win32", reason="Block backslash on Windows only" +) +def test_double_backslash_prohibited_on_win32( + app: Sanic, static_file_directory: str +): + app.static("/foo", static_file_directory) + + _, response = app.test_client.get("/foo/static/..\\static/test.file") + assert response.status == 400 + _, response = app.test_client.get("/foo/static\\../static/test.file") + assert response.status == 400