Compare commits

...

10 Commits

Author SHA1 Message Date
Adam Hopkins
0b750593da
Version Bump 2022-07-31 13:03:51 +03:00
Adam Hopkins
5b1686ceba
Use path.parts instead of match (#2508) 2022-07-31 12:58:21 +03:00
Adam Hopkins
86baaef1ec
Use pathlib for path resolution (#2506) 2022-07-31 12:58:18 +03:00
Adam Hopkins
2b4b78da88
Fix dotted test 2022-07-31 12:56:57 +03:00
Néstor Pérez
ee6d8cfe11
Prevent directory traversion with static files (#2495)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Zhiwei Liang <zhi.wei.liang@outlook.com>
2022-07-31 12:56:52 +03:00
Adam Hopkins
c4da66bf1f
Update changelog 2022-01-06 12:26:35 +02:00
Adam Hopkins
d50d3b8448
Bump version 2022-01-06 12:21:44 +02:00
Adam Hopkins
313f97ac77
Only display MOTD in ASGI on startup (#2349) 2022-01-06 11:22:57 +02:00
Adam Hopkins
a23547d73b
Ignore name argument on Python 3.7 (#2355)
Co-authored-by: Néstor Pérez <25409753+prryplatypus@users.noreply.github.com>
Co-authored-by: Ryu juheon <saidbysolo@gmail.com>
2022-01-06 10:57:24 +02:00
Adam Hopkins
34d1dee407
Add config.update support for setters (#2354) 2022-01-06 09:55:03 +02:00
23 changed files with 264 additions and 85 deletions

View File

@ -2,9 +2,13 @@ name: "CodeQL"
on: on:
push: push:
branches: [ main ] branches:
- main
- "*LTS"
pull_request: pull_request:
branches: [ main ] branches:
- main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
schedule: schedule:
- cron: '25 16 * * 0' - cron: '25 16 * * 0'

View File

@ -3,6 +3,7 @@ on:
push: push:
branches: branches:
- main - main
- "*LTS"
tags: tags:
- "!*" # Do not execute on tags - "!*" # Do not execute on tags
pull_request: pull_request:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:
@ -15,7 +16,7 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
config: config:
- { python-version: 3.7, tox-env: type-checking} # - { python-version: 3.7, tox-env: type-checking}
- { python-version: 3.8, tox-env: type-checking} - { python-version: 3.8, tox-env: type-checking}
- { python-version: 3.9, tox-env: type-checking} - { python-version: 3.9, tox-env: type-checking}
- { python-version: "3.10", tox-env: type-checking} - { python-version: "3.10", tox-env: type-checking}

View File

@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
jobs: jobs:

28
codecov.yml Normal file
View File

@ -0,0 +1,28 @@
coverage:
status:
patch:
default:
target: auto
threshold: 0.75
informational: true
project:
default:
target: auto
threshold: 0.5
precision: 3
codecov:
require_ci_to_pass: false
ignore:
- "sanic/__main__.py"
- "sanic/compat.py"
- "sanic/reloader_helpers.py"
- "sanic/simple.py"
- "sanic/utils.py"
- "sanic/cli"
- ".github/"
- "changelogs/"
- "docker/"
- "docs/"
- "examples/"
- "scripts/"
- "tests/"

View File

@ -1,3 +1,9 @@
## Version 21.12.1
- [#2349](https://github.com/sanic-org/sanic/pull/2349) Only display MOTD on startup
- [#2354](https://github.com/sanic-org/sanic/pull/2354) Ignore name argument in Python 3.7
- [#2355](https://github.com/sanic-org/sanic/pull/2355) Add config.update support for all config values
## Version 21.12.0 ## Version 21.12.0
### Features ### Features

View File

@ -1 +1 @@
__version__ = "21.12.0" __version__ = "21.12.2"

View File

@ -1552,10 +1552,19 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
name: Optional[str] = None, name: Optional[str] = None,
register: bool = True, register: bool = True,
) -> Task: ) -> Task:
prepped = cls._prep_task(task, app, loop) if not isinstance(task, Future):
task = loop.create_task(prepped, name=name) prepped = cls._prep_task(task, app, loop)
if sys.version_info < (3, 8):
if name:
error_logger.warning(
"Cannot set a name for a task when using Python 3.7. "
"Your task will be created without a name."
)
task = loop.create_task(prepped)
else:
task = loop.create_task(prepped, name=name)
if name and register: if name and register and sys.version_info > (3, 7):
app._task_registry[name] = task app._task_registry[name] = task
return task return task
@ -1617,10 +1626,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
def get_task( def get_task(
self, name: str, *, raise_exception: bool = True self, name: str, *, raise_exception: bool = True
) -> Optional[Task]: ) -> Optional[Task]:
if sys.version_info == (3, 7): if sys.version_info < (3, 8):
raise RuntimeError( error_logger.warning(
"This feature is only supported on using Python 3.8+." "This feature (get_task) is only supported on using "
"Python 3.8+."
) )
return
try: try:
return self._task_registry[name] return self._task_registry[name]
except KeyError: except KeyError:
@ -1637,10 +1648,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
*, *,
raise_exception: bool = True, raise_exception: bool = True,
) -> None: ) -> None:
if sys.version_info == (3, 7): if sys.version_info < (3, 8):
raise RuntimeError( error_logger.warning(
"This feature is only supported on using Python 3.8+." "This feature (cancel_task) is only supported on using "
"Python 3.8+."
) )
return
task = self.get_task(name, raise_exception=raise_exception) task = self.get_task(name, raise_exception=raise_exception)
if task and not task.cancelled(): if task and not task.cancelled():
args: Tuple[str, ...] = () args: Tuple[str, ...] = ()
@ -1659,10 +1672,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
... ...
def purge_tasks(self): def purge_tasks(self):
if sys.version_info == (3, 7): if sys.version_info < (3, 8):
raise RuntimeError( error_logger.warning(
"This feature is only supported on using Python 3.8+." "This feature (purge_tasks) is only supported on using "
"Python 3.8+."
) )
return
for task in self.tasks: for task in self.tasks:
if task.done() or task.cancelled(): if task.done() or task.cancelled():
name = task.get_name() name = task.get_name()
@ -1675,10 +1690,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
def shutdown_tasks( def shutdown_tasks(
self, timeout: Optional[float] = None, increment: float = 0.1 self, timeout: Optional[float] = None, increment: float = 0.1
): ):
if sys.version_info == (3, 7): if sys.version_info < (3, 8):
raise RuntimeError( error_logger.warning(
"This feature is only supported on using Python 3.8+." "This feature (shutdown_tasks) is only supported on using "
"Python 3.8+."
) )
return
for task in self.tasks: for task in self.tasks:
task.cancel() task.cancel()
@ -1692,10 +1709,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
@property @property
def tasks(self): def tasks(self):
if sys.version_info == (3, 7): if sys.version_info < (3, 8):
raise RuntimeError( error_logger.warning(
"This feature is only supported on using Python 3.8+." "This feature (tasks) is only supported on using "
"Python 3.8+."
) )
return
return iter(self._task_registry.values()) return iter(self._task_registry.values())
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #
@ -1709,7 +1728,8 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
details: https://asgi.readthedocs.io/en/latest details: https://asgi.readthedocs.io/en/latest
""" """
self.asgi = True self.asgi = True
self.motd("") if scope["type"] == "lifespan":
self.motd("")
self._asgi_app = await ASGIApp.create(self, scope, receive, send) self._asgi_app = await ASGIApp.create(self, scope, receive, send)
asgi_app = self._asgi_app asgi_app = self._asgi_app
await asgi_app() await asgi_app()

View File

@ -124,22 +124,27 @@ class Config(dict, metaclass=DescriptorMeta):
raise AttributeError(f"Config has no '{ke.args[0]}'") raise AttributeError(f"Config has no '{ke.args[0]}'")
def __setattr__(self, attr, value) -> None: def __setattr__(self, attr, value) -> None:
if attr in self.__class__.__setters__:
try:
super().__setattr__(attr, value)
except AttributeError:
...
else:
return None
self.update({attr: value}) self.update({attr: value})
def __setitem__(self, attr, value) -> None: def __setitem__(self, attr, value) -> None:
self.update({attr: value}) self.update({attr: value})
def update(self, *other, **kwargs) -> None: def update(self, *other, **kwargs) -> None:
other_mapping = {k: v for item in other for k, v in dict(item).items()} kwargs.update({k: v for item in other for k, v in dict(item).items()})
super().update(*other, **kwargs) setters: Dict[str, Any] = {
for attr, value in {**other_mapping, **kwargs}.items(): k: kwargs.pop(k)
for k in {**kwargs}.keys()
if k in self.__class__.__setters__
}
for key, value in setters.items():
try:
super().__setattr__(key, value)
except AttributeError:
...
super().update(**kwargs)
for attr, value in {**setters, **kwargs}.items():
self._post_set(attr, value) self._post_set(attr, value)
def _post_set(self, attr, value) -> None: def _post_set(self, attr, value) -> None:

View File

@ -4,8 +4,7 @@ from functools import partial, wraps
from inspect import getsource, signature from inspect import getsource, signature
from mimetypes import guess_type from mimetypes import guess_type
from os import path from os import path
from pathlib import PurePath from pathlib import Path, PurePath
from re import sub
from textwrap import dedent from textwrap import dedent
from time import gmtime, strftime from time import gmtime, strftime
from typing import Any, Callable, Iterable, List, Optional, Set, Tuple, Union from typing import Any, Callable, Iterable, List, Optional, Set, Tuple, Union
@ -17,12 +16,7 @@ from sanic.base.meta import SanicMeta
from sanic.compat import stat_async from sanic.compat import stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS
from sanic.errorpages import RESPONSE_MAPPING from sanic.errorpages import RESPONSE_MAPPING
from sanic.exceptions import ( from sanic.exceptions import ContentRangeError, FileNotFound, HeaderNotFound
ContentRangeError,
FileNotFound,
HeaderNotFound,
InvalidUsage,
)
from sanic.handlers import ContentRangeHandler from sanic.handlers import ContentRangeHandler
from sanic.log import deprecation, error_logger from sanic.log import deprecation, error_logger
from sanic.models.futures import FutureRoute, FutureStatic from sanic.models.futures import FutureRoute, FutureStatic
@ -775,32 +769,40 @@ class RouteMixin(metaclass=SanicMeta):
content_type=None, content_type=None,
__file_uri__=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 InvalidUsage("Invalid URL")
# Merge served directory and requested file if provided # Merge served directory and requested file if provided
# Strip all / that in the beginning of the URL to help prevent python file_path_raw = Path(unquote(file_or_directory))
# from herping a derp and treating the uri as an absolute path root_path = file_path = file_path_raw.resolve()
root_path = file_path = file_or_directory not_found = FileNotFound(
if __file_uri__: "File not found",
file_path = path.join( path=file_or_directory,
file_or_directory, sub("^[/]*", "", __file_uri__) relative_url=__file_uri__,
) )
# URL decode the path sent by the browser otherwise we won't be able to if __file_uri__:
# match filenames which got encoded (filenames with spaces etc) # Strip all / that in the beginning of the URL to help prevent
file_path = path.abspath(unquote(file_path)) # python from herping a derp and treating the uri as an
if not file_path.startswith(path.abspath(unquote(root_path))): # absolute path
error_logger.exception( unquoted_file_uri = unquote(__file_uri__).lstrip("/")
f"File not found: path={file_or_directory}, " file_path_raw = Path(file_or_directory, unquoted_file_uri)
f"relative_url={__file_uri__}" file_path = file_path_raw.resolve()
) if (
raise FileNotFound( file_path < root_path and not file_path_raw.is_symlink()
"File not found", ) or ".." in file_path_raw.parts:
path=file_or_directory, error_logger.exception(
relative_url=__file_uri__, f"File not found: path={file_or_directory}, "
) f"relative_url={__file_uri__}"
)
raise not_found
try:
file_path.relative_to(root_path)
except ValueError:
if not file_path_raw.is_symlink():
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
try: try:
headers = {} headers = {}
# Check if the client has been sent this file before # Check if the client has been sent this file before
@ -868,11 +870,7 @@ class RouteMixin(metaclass=SanicMeta):
except ContentRangeError: except ContentRangeError:
raise raise
except FileNotFoundError: except FileNotFoundError:
raise FileNotFound( raise not_found
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
except Exception: except Exception:
error_logger.exception( error_logger.exception(
f"Exception in static request handler: " f"Exception in static request handler: "

View File

@ -5,7 +5,7 @@ from os import environ
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from textwrap import dedent from textwrap import dedent
from unittest.mock import Mock from unittest.mock import Mock, call
import pytest import pytest
@ -385,5 +385,24 @@ def test_config_set_methods(app, monkeypatch):
post_set.assert_called_once_with("FOO", 5) post_set.assert_called_once_with("FOO", 5)
post_set.reset_mock() post_set.reset_mock()
app.config.update_config({"FOO": 6}) app.config.update({"FOO": 6}, {"BAR": 7})
post_set.assert_called_once_with("FOO", 6) post_set.assert_has_calls(
calls=[
call("FOO", 6),
call("BAR", 7),
]
)
post_set.reset_mock()
app.config.update({"FOO": 8}, BAR=9)
post_set.assert_has_calls(
calls=[
call("FOO", 8),
call("BAR", 9),
],
any_order=True,
)
post_set.reset_mock()
app.config.update_config({"FOO": 10})
post_set.assert_called_once_with("FOO", 10)

View File

@ -2,6 +2,7 @@ import asyncio
import sys import sys
from threading import Event from threading import Event
from unittest.mock import Mock
import pytest import pytest
@ -77,6 +78,25 @@ def test_create_named_task(app):
app.run() app.run()
def test_named_task_called(app):
e = Event()
async def coro():
e.set()
@app.route("/")
async def isset(request):
await asyncio.sleep(0.05)
return text(str(e.is_set()))
@app.before_server_start
async def setup(app, _):
app.add_task(coro, name="dummy_task")
request, response = app.test_client.get("/")
assert response.body == b"True"
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") @pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7")
def test_create_named_task_fails_outside_app(app): def test_create_named_task_fails_outside_app(app):
async def dummy(): async def dummy():

View File

@ -334,6 +334,22 @@ def test_config_fallback_before_and_after_startup(app):
assert response.content_type == "text/plain; charset=utf-8" assert response.content_type == "text/plain; charset=utf-8"
def test_config_fallback_using_update_dict(app):
app.config.update({"FALLBACK_ERROR_FORMAT": "text"})
_, response = app.test_client.get("/error")
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
def test_config_fallback_using_update_kwarg(app):
app.config.update(FALLBACK_ERROR_FORMAT="text")
_, response = app.test_client.get("/error")
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
def test_config_fallback_bad_value(app): def test_config_fallback_bad_value(app):
message = "Unknown format: fake" message = "Unknown format: fake"
with pytest.raises(SanicException, match=message): with pytest.raises(SanicException, match=message):

View File

@ -62,19 +62,15 @@ def test_streaming_body_requests(app):
data = ["hello", "world"] data = ["hello", "world"]
class Data(AsyncByteStream):
def __init__(self, data):
self.data = data
async def __aiter__(self):
for value in self.data:
yield value.encode("utf-8")
client = ReusableClient(app, port=1234) client = ReusableClient(app, port=1234)
async def stream(data):
for value in data:
yield value.encode("utf-8")
with client: with client:
_, response1 = client.post("/", data=Data(data)) _, response1 = client.post("/", data=stream(data))
_, response2 = client.post("/", data=Data(data)) _, response2 = client.post("/", data=stream(data))
assert response1.status == response2.status == 200 assert response1.status == response2.status == 200
assert response1.json["data"] == response2.json["data"] == data assert response1.json["data"] == response2.json["data"] == data

View File

@ -4,8 +4,8 @@ from unittest.mock import Mock, patch
import pytest import pytest
from sanic.server import loop
from sanic.compat import OS_IS_WINDOWS, UVLOOP_INSTALLED from sanic.compat import OS_IS_WINDOWS, UVLOOP_INSTALLED
from sanic.server import loop
@pytest.mark.skipif( @pytest.mark.skipif(

View File

@ -1,6 +1,7 @@
import inspect import inspect
import logging import logging
import os import os
import sys
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
@ -8,7 +9,7 @@ from time import gmtime, strftime
import pytest import pytest
from sanic import text from sanic import Sanic, text
from sanic.exceptions import FileNotFound from sanic.exceptions import FileNotFound
@ -21,6 +22,22 @@ def static_file_directory():
return static_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): def get_file_path(static_file_directory, file_name):
return os.path.join(static_file_directory, file_name) return os.path.join(static_file_directory, file_name)
@ -578,3 +595,43 @@ def test_resource_type_dir(app, static_file_directory):
def test_resource_type_unknown(app, static_file_directory, caplog): def test_resource_type_unknown(app, static_file_directory, caplog):
with pytest.raises(ValueError): with pytest.raises(ValueError):
app.static("/static", static_file_directory, resource_type="unknown") 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)
dot_relative_path = str(
double_dotted_directory_file.relative_to(static_file_directory)
)
_, response = app.test_client.get("/foo/" + dot_relative_path)
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/..%2Ffake/server.py")
assert response.status == 404
_, response = app.test_client.get("/foo/..%2Fstatic/test.file")
assert response.status == 404
@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 == 404
_, response = app.test_client.get("/foo/static\\../static/test.file")
assert response.status == 404