Skip to content

Commit 5772176

Browse files
committed
handle unsafe user provided paths
1 parent 1236942 commit 5772176

File tree

6 files changed

+44
-30
lines changed

6 files changed

+44
-30
lines changed

requirements/pkg-deps.txt

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
typing-extensions >=3.10
22
mypy-extensions >=0.4.3
3-
anyio >=3.0
3+
anyio >=3
44
jsonpatch >=1.32
55
fastjsonschema >=2.14.5
6-
requests >=2.0
6+
requests >=2
77
colorlog >=6
8+
werkzeug >=2

src/idom/server/flask.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,21 @@
1717
Request,
1818
copy_current_request_context,
1919
request,
20-
send_from_directory,
20+
send_file,
2121
)
2222
from flask_cors import CORS
2323
from flask_sock import Sock
2424
from simple_websocket import Server as WebSocket
2525
from werkzeug.serving import BaseWSGIServer, make_server
2626

2727
import idom
28-
from idom.config import IDOM_WEB_MODULES_DIR
2928
from idom.core.hooks import Context, create_context, use_context
3029
from idom.core.layout import LayoutEvent, LayoutUpdate
3130
from idom.core.serve import serve_json_patch
3231
from idom.core.types import ComponentType, RootComponentConstructor
3332
from idom.utils import Ref
3433

35-
from .utils import CLIENT_BUILD_DIR, client_build_dir_path
34+
from .utils import safe_client_build_dir_path, safe_web_modules_dir_path
3635

3736

3837
logger = logging.getLogger(__name__)
@@ -159,18 +158,15 @@ def _setup_common_routes(blueprint: Blueprint, options: Options) -> None:
159158
@blueprint.route("/")
160159
@blueprint.route("/<path:path>")
161160
def send_client_dir(path: str = "") -> Any:
162-
return send_from_directory(
163-
str(CLIENT_BUILD_DIR),
164-
client_build_dir_path(path),
165-
)
161+
return send_file(safe_client_build_dir_path(path))
166162

167163
@blueprint.route(r"/_api/modules/<path:path>")
168164
@blueprint.route(r"<path:_>/_api/modules/<path:path>")
169165
def send_modules_dir(
170166
path: str,
171167
_: str = "", # this is not used
172168
) -> Any:
173-
return send_from_directory(str(IDOM_WEB_MODULES_DIR.current), path)
169+
return send_file(safe_web_modules_dir_path(path))
174170

175171

176172
def _setup_single_view_dispatcher_route(
@@ -258,7 +254,7 @@ def run_send() -> None:
258254
dispatch_thread_info.dispatch_loop.call_soon_threadsafe(
259255
dispatch_thread_info.async_recv_queue.put_nowait, value
260256
)
261-
finally:
257+
finally: # pragma: no cover
262258
dispatch_thread_info.dispatch_loop.call_soon_threadsafe(
263259
dispatch_thread_info.dispatch_future.cancel
264260
)

src/idom/server/sanic.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from sanic_cors import CORS
1515
from websockets.legacy.protocol import WebSocketCommonProtocol
1616

17-
from idom.config import IDOM_WEB_MODULES_DIR
1817
from idom.core.hooks import Context, create_context, use_context
1918
from idom.core.layout import Layout, LayoutEvent
2019
from idom.core.serve import (
@@ -27,7 +26,7 @@
2726
from idom.core.types import RootComponentConstructor
2827

2928
from ._asgi import serve_development_asgi
30-
from .utils import CLIENT_BUILD_DIR, client_build_dir_path
29+
from .utils import safe_client_build_dir_path, safe_web_modules_dir_path
3130

3231

3332
logger = logging.getLogger(__name__)
@@ -122,7 +121,7 @@ async def single_page_app_files(
122121
path: str = "",
123122
) -> response.HTTPResponse:
124123
path = urllib_parse.unquote(path)
125-
return await response.file(CLIENT_BUILD_DIR / client_build_dir_path(path))
124+
return await response.file(safe_client_build_dir_path(path))
126125

127126
blueprint.add_route(single_page_app_files, "/")
128127
blueprint.add_route(single_page_app_files, "/<path:path>")
@@ -132,9 +131,8 @@ async def web_module_files(
132131
path: str,
133132
_: str = "", # this is not used
134133
) -> response.HTTPResponse:
135-
wm_dir = IDOM_WEB_MODULES_DIR.current
136134
path = urllib_parse.unquote(path)
137-
return await response.file(wm_dir.joinpath(*path.split("/")))
135+
return await response.file(safe_web_modules_dir_path(path))
138136

139137
blueprint.add_route(web_module_files, "/_api/modules/<path:path>")
140138
blueprint.add_route(web_module_files, "/<_:path>/_api/modules/<path:path>")

src/idom/server/starlette.py

+5-6
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from idom.core.types import RootComponentConstructor
2525

2626
from ._asgi import serve_development_asgi
27-
from .utils import CLIENT_BUILD_DIR, client_build_dir_path
27+
from .utils import CLIENT_BUILD_DIR, safe_client_build_dir_path
2828

2929

3030
logger = logging.getLogger(__name__)
@@ -133,11 +133,10 @@ def single_page_app_files() -> Callable[..., Awaitable[None]]:
133133
)
134134

135135
async def spa_app(scope: Scope, receive: Receive, send: Send) -> None:
136-
return await static_files_app(
137-
{**scope, "path": client_build_dir_path(scope["path"])},
138-
receive,
139-
send,
140-
)
136+
# Path safety is the responsibility of starlette.staticfiles.StaticFiles -
137+
# using `safe_client_build_dir_path` is for convenience in this case.
138+
path = safe_client_build_dir_path(scope["path"]).name
139+
return await static_files_app({**scope, "path": path}, receive, send)
141140

142141
return spa_app
143142

src/idom/server/tornado.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from idom.core.serve import VdomJsonPatch, serve_json_patch
2323
from idom.core.types import ComponentConstructor
2424

25-
from .utils import CLIENT_BUILD_DIR, client_build_dir_path
25+
from .utils import CLIENT_BUILD_DIR, safe_client_build_dir_path
2626

2727

2828
RequestContext: type[Context[HTTPServerRequest | None]] = create_context(
@@ -165,7 +165,9 @@ def _setup_single_view_dispatcher_route(
165165

166166
class SpaStaticFileHandler(StaticFileHandler):
167167
async def get(self, path: str, include_body: bool = True) -> None:
168-
return await super().get(client_build_dir_path(path), include_body)
168+
# Path safety is the responsibility of tornado.web.StaticFileHandler -
169+
# using `safe_client_build_dir_path` is for convenience in this case.
170+
return await super().get(safe_client_build_dir_path(path).name, include_body)
169171

170172

171173
class ModelStreamHandler(WebSocketHandler):

src/idom/server/utils.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from pathlib import Path
99
from typing import Any, Iterator
1010

11+
from werkzeug.security import safe_join
12+
1113
import idom
14+
from idom.config import IDOM_WEB_MODULES_DIR
1215
from idom.types import RootComponentConstructor
1316

1417
from .types import ServerImplementation
@@ -26,12 +29,6 @@
2629
)
2730

2831

29-
def client_build_dir_path(path: str) -> str:
30-
start, _, end = path.rpartition("/")
31-
file = end or start
32-
return file if (CLIENT_BUILD_DIR / file).is_file() else "index.html"
33-
34-
3532
def run(
3633
component: RootComponentConstructor,
3734
host: str = "127.0.0.1",
@@ -62,6 +59,27 @@ def run(
6259
)
6360

6461

62+
def safe_client_build_dir_path(path: str) -> Path:
63+
"""Prevent path traversal out of :data:`CLIENT_BUILD_DIR`"""
64+
start, _, end = path.rpartition("/")
65+
file = end or start
66+
final_path = traversal_safe_path(CLIENT_BUILD_DIR, file)
67+
return final_path if final_path.is_file() else (CLIENT_BUILD_DIR / "index.html")
68+
69+
70+
def safe_web_modules_dir_path(path: str) -> Path:
71+
"""Prevent path traversal out of :data:`idom.config.IDOM_WEB_MODULES_DIR`"""
72+
return traversal_safe_path(IDOM_WEB_MODULES_DIR.current, *path.split("/"))
73+
74+
75+
def traversal_safe_path(root: Path, *unsafe_parts: str | Path) -> Path:
76+
"""Sanitize user given path using ``werkzeug.security.safe_join``"""
77+
path = safe_join(str(root.resolve()), *unsafe_parts)
78+
if path is None:
79+
raise ValueError("Unsafe path") # pragma: no cover
80+
return Path(path)
81+
82+
6583
def find_available_port(
6684
host: str,
6785
port_min: int = 8000,

0 commit comments

Comments
 (0)