diff --git a/src/workbench/server.py b/src/workbench/server.py index 22eda9f..9f33cf2 100644 --- a/src/workbench/server.py +++ b/src/workbench/server.py @@ -102,8 +102,10 @@ class WorkbenchServer: async def static_handler(request): proj = request.app["project_name"] path = request.match_info.get("path", "index.html") or "index.html" - file_path = project_path(proj, self.workbench_dir) / path - if not file_path.exists(): + root = project_path(proj, self.workbench_dir).resolve() + file_path = (root / path).resolve() + # served on the LAN — never let "../" escape the project dir + if not file_path.is_relative_to(root) or not file_path.is_file(): return web.Response(status=404, text="Not found") return web.FileResponse(file_path) diff --git a/tests/test_server.py b/tests/test_server.py index aaf0f5a..caab25f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -103,3 +103,24 @@ async def test_reconnect_on_startup(tmp_workbench): assert srv._active["live-proj"]["port"] == 8070 assert "dead-proj" not in srv._active assert not (tmp_workbench / "dead-proj" / ".server.json").exists() + + +async def _live(server, name="p"): + import aiohttp + from workbench.project import create_project + create_project(name, "T", workbench_dir=server.workbench_dir) + port = await server._start_http_server(name) + return aiohttp.ClientSession(), f"http://127.0.0.1:{port}" + + +@pytest.mark.asyncio +async def test_static_blocks_path_traversal(server, tmp_workbench): + (tmp_workbench / "secret.txt").write_text("nope") + session, base = await _live(server) + async with session: + for evil in ["/../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt"]: + async with session.get(base + evil, allow_redirects=False) as r: + assert r.status == 404, evil + async with session.get(base + "/") as r: + assert r.status == 200 + await server.workbench_stop("p")