Skip to content

Commit a936c86

Browse files
committed
better path stuff
1 parent a936c66 commit a936c86

File tree

3 files changed

+45
-37
lines changed

3 files changed

+45
-37
lines changed

src/py/reactpy/reactpy/backend/_common.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,17 @@ async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> N
7171

7272
def safe_client_build_dir_path(path: str) -> Path:
7373
"""Prevent path traversal out of :data:`CLIENT_BUILD_DIR`"""
74-
return traversal_safe_path(
75-
CLIENT_BUILD_DIR,
76-
*("index.html" if path in ("", "/") else path).split("/"),
74+
return safe_join_path(
75+
CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/")
7776
)
7877

7978

8079
def safe_web_modules_dir_path(path: str) -> Path:
8180
"""Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`"""
82-
return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/"))
81+
return safe_join_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/"))
8382

8483

85-
def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path:
84+
def safe_join_path(root: str | Path, *unsafe: str | Path) -> Path:
8685
"""Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir."""
8786
root = os.path.abspath(root)
8887

@@ -92,8 +91,9 @@ def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path:
9291

9392
if os.path.commonprefix([root, path]) != root:
9493
# If the common prefix is not root directory we resolved outside the root dir
95-
msg = "Unsafe path"
96-
raise ValueError(msg)
94+
raise ValueError(
95+
f"Unsafe path detected. Path '{path}' is outside root directory '{root}'"
96+
)
9797

9898
return Path(path)
9999

src/py/reactpy/reactpy/backend/asgi.py

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,16 @@
44
import os
55
import re
66
import urllib.parse
7-
from collections.abc import Sequence
7+
from collections.abc import Coroutine, Sequence
88
from pathlib import Path
9-
from typing import Coroutine
109

1110
import aiofiles
1211
import orjson
1312
from asgiref.compatibility import guarantee_single_callable
1413

1514
from reactpy.backend._common import (
1615
CLIENT_BUILD_DIR,
17-
traversal_safe_path,
16+
safe_join_path,
1817
vdom_head_elements_to_html,
1918
)
2019
from reactpy.backend.hooks import ConnectionContext
@@ -25,7 +24,6 @@
2524
from reactpy.core.serve import serve_layout
2625
from reactpy.core.types import ComponentConstructor, VdomDict
2726

28-
DEFAULT_STATIC_PATH = f"{os.getcwd()}/static"
2927
DEFAULT_BLOCK_SIZE = 8192
3028
_logger = logging.getLogger(__name__)
3129

@@ -38,7 +36,7 @@ def __init__(
3836
dispatcher_path: str = "^reactpy/([^/]+)/?",
3937
js_modules_path: str | None = "^reactpy/modules/([^/]+)/?",
4038
static_path: str | None = "^reactpy/static/([^/]+)/?",
41-
static_dir: str | None = DEFAULT_STATIC_PATH,
39+
static_dir: Path | str | None = None,
4240
head: Sequence[VdomDict] | VdomDict | str = "",
4341
) -> None:
4442
self.component = (
@@ -56,8 +54,8 @@ def __init__(
5654
"The first argument to `ReactPy` must be a component or an ASGI application."
5755
)
5856
self.dispatch_path = re.compile(dispatcher_path)
59-
self.js_modules_path = re.compile(js_modules_path)
60-
self.static_path = re.compile(static_path)
57+
self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None
58+
self.static_path = re.compile(static_path) if static_path else None
6159
self.static_dir = static_dir
6260
self.all_paths = re.compile(
6361
"|".join(
@@ -88,14 +86,20 @@ async def __call__(self, scope, receive, send) -> None:
8886
return
8987

9088
# Route requests to our JS web module app
91-
if scope["type"] == "http" and re.match(
92-
self.js_modules_path, scope["path"]
89+
if (
90+
scope["type"] == "http"
91+
and self.js_modules_path
92+
and re.match(self.js_modules_path, scope["path"])
9393
):
9494
await self.js_modules_app(scope, receive, send)
9595
return
9696

9797
# Route requests to our static file server app
98-
if scope["type"] == "http" and re.match(self.static_path, scope["path"]):
98+
if (
99+
scope["type"] == "http"
100+
and self.static_path
101+
and re.match(self.static_path, scope["path"])
102+
):
99103
await self.static_file_app(scope, receive, send)
100104
return
101105

@@ -159,45 +163,42 @@ async def js_modules_app(self, scope, receive, send) -> None:
159163
"""The ASGI application for ReactPy web modules."""
160164

161165
if not REACTPY_WEB_MODULES_DIR.current:
162-
raise RuntimeError("No web modules directory configured")
163-
164-
# Get the relative file path from the URL
165-
file_url_path = re.match(self.js_modules_path, scope["path"])[1]
166+
raise RuntimeError("No web modules directory configured.")
166167

167168
# Make sure the user hasn't tried to escape the web modules directory
168169
try:
169-
file_path = traversal_safe_path(
170+
abs_file_path = safe_join_path(
170171
REACTPY_WEB_MODULES_DIR.current,
171172
REACTPY_WEB_MODULES_DIR.current,
172-
file_url_path,
173+
re.match(self.js_modules_path, scope["path"])[1],
173174
)
174175
except ValueError:
175176
await simple_response(send, 403, "Forbidden")
176177
return
177178

178179
# Serve the file
179-
await file_response(scope, send, file_path)
180+
await file_response(scope, send, abs_file_path)
180181

181182
async def static_file_app(self, scope, receive, send) -> None:
182183
"""The ASGI application for ReactPy static files."""
183184

184-
if self.static_dir is None:
185-
raise RuntimeError("No static directory configured")
186-
187-
# Get the relative file path from the URL
188-
file_url_path = re.match(self.static_path, scope["path"])[1]
185+
if not self.static_dir:
186+
raise RuntimeError(
187+
"Static files cannot be served without defining `static_dir`."
188+
)
189189

190190
# Make sure the user hasn't tried to escape the static directory
191191
try:
192-
file_path = traversal_safe_path(
193-
self.static_dir, self.static_dir, file_url_path
192+
abs_file_path = safe_join_path(
193+
self.static_dir,
194+
re.match(self.static_path, scope["path"])[1],
194195
)
195196
except ValueError:
196197
await simple_response(send, 403, "Forbidden")
197198
return
198199

199200
# Serve the file
200-
await file_response(scope, send, file_path)
201+
await file_response(scope, send, abs_file_path)
201202

202203
async def index_html_app(self, scope, receive, send) -> None:
203204
"""The ASGI application for ReactPy index.html."""
@@ -289,7 +290,9 @@ async def file_response(scope, send, file_path: Path) -> None:
289290
)
290291
if mime_type is None:
291292
mime_type = "text/plain"
292-
_logger.error(f"Could not determine MIME type for {file_path}.")
293+
_logger.error(
294+
f"Could not determine MIME type for {file_path}. Defaulting to 'text/plain'."
295+
)
293296

294297
# Send the file in chunks
295298
async with aiofiles.open(file_path, "rb") as file_handle:
@@ -299,7 +302,12 @@ async def file_response(scope, send, file_path: Path) -> None:
299302
"status": 200,
300303
"headers": [
301304
(b"content-type", mime_type.encode()),
302-
(b"last-modified", str(os.path.getmtime(file_path)).encode()),
305+
(
306+
b"last-modified",
307+
str(
308+
await asyncio.to_thread(os.path.getmtime, file_path)
309+
).encode(),
310+
),
303311
],
304312
}
305313
)

src/py/reactpy/tests/test_backend/test__common.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from reactpy import html
44
from reactpy.backend._common import (
55
CommonOptions,
6-
traversal_safe_path,
6+
safe_join_path,
77
vdom_head_elements_to_html,
88
)
99

@@ -25,8 +25,8 @@ def test_common_options_url_prefix_starts_with_slash():
2525
],
2626
)
2727
def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path):
28-
with pytest.raises(ValueError, match="Unsafe path"):
29-
traversal_safe_path(tmp_path, *bad_path.split("/"))
28+
with pytest.raises(ValueError):
29+
safe_join_path(tmp_path, *bad_path.split("/"))
3030

3131

3232
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)