Repository: sanic-org/sanic

Issue:
Static routes containing ".." don't work
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

Sanic does serve static files if the server file path contains "..", instead it returns file not found errors.

### Code snippet

The following doesn't work:
```
from sanic import Sanic

app = Sanic("Example")
app.static("/", "./static/../static/")
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)
```
Attempt to access any files in the static directory will get the error:
`[2023-03-27 04:05:31 -0700] [60813] [ERROR] File not found: path=static/../static, relative_url=test.txt`
Removing the redundant `../static/` will result in the files being correctly served.

### Expected Behavior

The above example should serve all files in the static directory and not return file not found errors.

### How do you run Sanic?

Sanic CLI

### Operating System

linux

### Sanic Version

22.9.1

### Additional context

This issue appears to have been introduced by #2506 or #2508.
The fix might look something like the following:
```
diff --git a/sanic/mixins/static.py b/sanic/mixins/static.py
index bcffbc8..28effc4 100644
--- a/sanic/mixins/static.py
+++ b/sanic/mixins/static.py
@@ -323,7 +323,7 @@ class StaticHandleMixin(metaclass=SanicMeta):
             # python from herping a derp and treating the uri as an
             # absolute path
             unquoted_file_uri = unquote(__file_uri__).lstrip("/")
-            file_path_raw = Path(file_or_directory, unquoted_file_uri)
+            file_path_raw = Path(root_path, unquoted_file_uri)
             file_path = file_path_raw.resolve()
             if (
                 file_path < root_path and not file_path_raw.is_symlink()
```

It looks like the ".." was originally being done on the URI part only but with the #2506 change this is now done on the combination of the root path *and* relative path. The above change instead uses the resolved form of the root path (which won't contain "..").

However, there are likely other options and having more context about what the checks in this area are for would be crucial.


Discussion:
Looks like Sanic should resolve that static dir path when the route is defined, making it an absolute path without any `..` elements. If that is the only thing missing, it should be easy to fix this. PRs welcome, if you are up to that :)
https://github.com/sanic-org/sanic/blob/main/sanic/mixins/static.py#L98

All we need is a `.resolve()` on this line and a test.

Resolving patch:
diff --git a/sanic/__version__.py b/sanic/__version__.py
--- a/sanic/__version__.py
+++ b/sanic/__version__.py
@@ -1 +1 @@
-__version__ = "23.3.0"
+__version__ = "23.3.1"
diff --git a/sanic/mixins/static.py b/sanic/mixins/static.py
--- a/sanic/mixins/static.py
+++ b/sanic/mixins/static.py
@@ -95,7 +95,7 @@ def static(
             )
 
         try:
-            file_or_directory = Path(file_or_directory)
+            file_or_directory = Path(file_or_directory).resolve()
         except TypeError:
             raise TypeError(
                 "Static file or directory must be a path-like object or string"


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -101,6 +101,31 @@ def test_static_file_pathlib(app, static_file_directory, file_name):
     assert response.body == get_file_content(static_file_directory, file_name)
 
 
+@pytest.mark.parametrize(
+    "file_name",
+    [
+        "test.file",
+        "decode me.txt",
+        "python.png",
+        "symlink",
+        "hard_link",
+    ],
+)
+def test_static_file_pathlib_relative_path_traversal(
+    app, static_file_directory, file_name
+):
+    """Get the current working directory and check if it ends with "sanic" """
+    cwd = Path.cwd()
+    if not str(cwd).endswith("sanic"):
+        pytest.skip("Current working directory does not end with 'sanic'")
+
+    file_path = "./tests/static/../static/"
+    app.static("/", file_path)
+    _, response = app.test_client.get(f"/{file_name}")
+    assert response.status == 200
+    assert response.body == get_file_content(static_file_directory, file_name)
+
+
 @pytest.mark.parametrize(
     "file_name",
     [b"test.file", b"decode me.txt", b"python.png"],