Repository: sanic-org/sanic

Issue:
Sanic static handler allows parent ".." directory traversal
**Describe the bug**

The sanic static directory code checks for `../` as a substring of paths, but it also unquotes the path, which allows a malicious user to escape outside the static folder by using `..%2F`, where `%2F` is the URL-escaped version of `/`.

**Code snippet**

First, a basic server called `main.py`.

```python
from sanic import Sanic

app = Sanic(name="sanic_test")

app.static('/static', './static_files')

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)
```

Then create a static file folder.
```
mkdir static_files
cat "hello world" > static_files/a.txt
```

Now run the server with `python3 main.py` and:

```sh-session
$ curl http://localhost:8000/static/a.txt
hello world
$ curl http://localhost:8000/static/..%2Fstatic_files/a.txt
hello world
```

This is very surprising behavior. From a security perspective it is not critical because sanic checks that the final resolved path has a prefix with the static directory, but this allows an attacker to expose information like the name of the static file folder.

Another case where this is dangerous is if you have a middleware that only allows a user to see certain subpaths like `/static/public/**` of the `/static/**` routes without authentication. Then, even without authentication, a user could visit a path like `/static/public/..%2F/private/secret_content.txt` and retrieve the contents of `/static/private/secret_content.txt`.

**Expected behavior**

Sanic should not allow parent directory traversal in static folders.

**Environment (please complete the following information):**
 - OS: macOS and Linux
 - Version 22.3.2



Discussion:
Related to #2477.

I suppose this can also be exploited by using backslashes, which on Windows would be interpreted as path separators. I suggest denying any escaped slashes and plain backslashes. Processing of `..` (and `.`) ought to be preserved in a logical form, possibly yielding a redirect response, but never traversing parents on the actual filesystem. Somewhat related, all path elements that begin with a dot could be banned (avoiding access to hidden files, which also could be leaking data not intended public).

Python `pathlib` module would be a reasonable option for path processing, avoiding Sanic's own parsing, but concerns include performance and the said special cases with slashes being escaped. Also, whether URL sanitation (avoiding `.` `..` `//` and other oddities) should be applied on all requests and not only static files.

Just my 5 cents. I didn't look at the current code, and in any case this probably needs more thought for a solid implementation.
Most browsers and HTTP clients (like cURL) will automatically apply [path normalization](https://en.wikipedia.org/wiki/URI_normalization#Normalizations_that_preserve_semantics) to URLs, which includes removing dot segments. It's not really necessary to process `..` as a result.
~~Funnily enough it looks like we already do some kind of check, but we do so before unquoting it :S~~
EDIT: I misunderstood the issue, my bad!

Resolving patch:
diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py
--- a/sanic/mixins/routes.py
+++ b/sanic/mixins/routes.py
@@ -3,9 +3,8 @@
 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 @@ async def _static_request_handler(
         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
+        root_path = file_path = path.abspath(unquote(file_or_directory))
+
         if __file_uri__:
-            file_path = path.join(
-                file_or_directory, sub("^[/]*", "", __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)
 
-        # 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 not file_path.startswith(root_path):
             error_logger.exception(
                 f"File not found: path={file_or_directory}, "
                 f"relative_url={__file_uri__}"


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
--- 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 @@
 
 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